diff --git a/.gitignore b/.gitignore
index b8b1353..81836f2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,6 @@ ovr-platform-util.exe
# Build counter
.buildcount
/.claude
+
+# Tools
+node_modules/
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2d56342..c91af52 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -7,6 +7,14 @@
+
+
+
+
+
+
+
+
+#include
+#include
+
+#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 snapshot;
+ {
+ std::lock_guard 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 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 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 lock(layerMutex);
+ for (auto& layer : layers) {
+ if (layer.id == layerId) {
+ layer.transform = transform;
+ return;
+ }
+ }
+}
+
+void CompositionPipeline::UpdateLayerOpacity(int layerId, float opacity) {
+ std::lock_guard lock(layerMutex);
+ for (auto& layer : layers) {
+ if (layer.id == layerId) {
+ layer.opacity = opacity;
+ return;
+ }
+ }
+}
+
+void CompositionPipeline::SetLayerEnabled(int layerId, bool enabled) {
+ std::lock_guard 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 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");
+}
diff --git a/app/src/main/cpp/composition_pipeline.h b/app/src/main/cpp/composition_pipeline.h
new file mode 100644
index 0000000..783d4f5
--- /dev/null
+++ b/app/src/main/cpp/composition_pipeline.h
@@ -0,0 +1,108 @@
+#pragma once
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+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 layers;
+ int nextLayerId = 1;
+};
diff --git a/app/src/main/cpp/egl_context.cpp b/app/src/main/cpp/egl_context.cpp
index 013b4eb..8f5db4f 100644
--- a/app/src/main/cpp/egl_context.cpp
+++ b/app/src/main/cpp/egl_context.cpp
@@ -1,6 +1,7 @@
#include "egl_context.h"
#include
+#include
#include
#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;
diff --git a/app/src/main/cpp/egl_context.h b/app/src/main/cpp/egl_context.h
index 62dc664..c36fb9e 100644
--- a/app/src/main/cpp/egl_context.h
+++ b/app/src/main/cpp/egl_context.h
@@ -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;
diff --git a/app/src/main/cpp/jni_bridge.cpp b/app/src/main/cpp/jni_bridge.cpp
index 56de371..eb0220d 100644
--- a/app/src/main/cpp/jni_bridge.cpp
+++ b/app/src/main/cpp/jni_bridge.cpp
@@ -2,6 +2,7 @@
#include
#include
+#include
#include
#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(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(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(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(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(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(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(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(ptr);
+ if (!engine) return;
+ engine->SetCompositionLayerEnabled(layerId, enabled == JNI_TRUE);
+}
+
} // extern "C"
diff --git a/app/src/main/cpp/streaming_engine.cpp b/app/src/main/cpp/streaming_engine.cpp
index cae0db9..105be43 100644
--- a/app/src/main/cpp/streaming_engine.cpp
+++ b/app/src/main/cpp/streaming_engine.cpp
@@ -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 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(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 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(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 lock(previewMutex);
+ pendingPreviewOps.push_back(PreviewSetOp{window});
+}
+
+void StreamingEngine::RemovePreviewSurface() {
+ std::lock_guard lock(previewMutex);
+ pendingPreviewOps.push_back(PreviewRemoveOp{});
+}
+
+void StreamingEngine::ProcessPendingPreviewOps() {
+ std::vector ops;
+ {
+ std::lock_guard lock(previewMutex);
+ ops.swap(pendingPreviewOps);
+ }
+
+ for (auto& op : ops) {
+ if (auto* setOp = std::get_if(&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(&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 lock(layerOpMutex);
+ pendingLayerOps.push_back(std::move(addOp));
+ return id;
+}
+
+void StreamingEngine::RemoveCompositionLayer(int layerId) {
+ std::lock_guard 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 lock(layerOpMutex);
+ pendingLayerOps.push_back(LayerTransformOp{layerId, posX, posY, scaleX, scaleY, rotation});
+}
+
+void StreamingEngine::UpdateCompositionLayerOpacity(int layerId, float opacity) {
+ std::lock_guard lock(layerOpMutex);
+ pendingLayerOps.push_back(LayerOpacityOp{layerId, opacity});
+}
+
+void StreamingEngine::SetCompositionLayerEnabled(int layerId, bool enabled) {
+ std::lock_guard lock(layerOpMutex);
+ pendingLayerOps.push_back(LayerEnabledOp{layerId, enabled});
+}
+
+void StreamingEngine::ProcessPendingLayerOps() {
+ std::vector ops;
+ {
+ std::lock_guard lock(layerOpMutex);
+ ops.swap(pendingLayerOps);
+ }
+
+ for (auto& op : ops) {
+ if (auto* addOp = std::get_if(&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(&op)) {
+ compositionPipeline.RemoveLayer(removeOp->layerId);
+ } else if (auto* transformOp = std::get_if(&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(&op)) {
+ compositionPipeline.UpdateLayerOpacity(opacityOp->layerId, opacityOp->opacity);
+ } else if (auto* enabledOp = std::get_if(&op)) {
+ compositionPipeline.SetLayerEnabled(enabledOp->layerId, enabledOp->enabled);
+ }
+ }
+}
diff --git a/app/src/main/cpp/streaming_engine.h b/app/src/main/cpp/streaming_engine.h
index 0574023..2ed6092 100644
--- a/app/src/main/cpp/streaming_engine.h
+++ b/app/src/main/cpp/streaming_engine.h
@@ -1,6 +1,7 @@
#pragma once
#include "egl_context.h"
+#include "composition_pipeline.h"
#include "rtmp_sink.h"
#include
@@ -14,6 +15,7 @@
#include
#include
#include
+#include
#include
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 audioQueue;
+ // Preview surface — pending ops from non-GL threads
+ struct PreviewSetOp { ANativeWindow* window; };
+ struct PreviewRemoveOp {};
+ using PreviewOp = std::variant;
+ std::mutex previewMutex;
+ std::vector pendingPreviewOps;
+ bool hasPreview = false;
+
+ // Layer ops — pending ops from non-GL threads
+ struct LayerAddOp {
+ std::vector 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;
+ std::mutex layerOpMutex;
+ std::vector pendingLayerOps;
+ std::atomic 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;
diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/local/LckDatabase.kt b/app/src/main/java/com/omixlab/lckcontrol/data/local/LckDatabase.kt
index 90c2ca2..45f9bc0 100644
--- a/app/src/main/java/com/omixlab/lckcontrol/data/local/LckDatabase.kt
+++ b/app/src/main/java/com/omixlab/lckcontrol/data/local/LckDatabase.kt
@@ -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")
+ }
+ }
}
}
diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/local/entity/LinkedAccountEntity.kt b/app/src/main/java/com/omixlab/lckcontrol/data/local/entity/LinkedAccountEntity.kt
index 77d9f67..003c6ee 100644
--- a/app/src/main/java/com/omixlab/lckcontrol/data/local/entity/LinkedAccountEntity.kt
+++ b/app/src/main/java/com/omixlab/lckcontrol/data/local/entity/LinkedAccountEntity.kt
@@ -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,
)
diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/remote/ApiModels.kt b/app/src/main/java/com/omixlab/lckcontrol/data/remote/ApiModels.kt
index 34bedaf..4ef5674 100644
--- a/app/src/main/java/com/omixlab/lckcontrol/data/remote/ApiModels.kt
+++ b/app/src/main/java/com/omixlab/lckcontrol/data/remote/ApiModels.kt
@@ -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)
diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/remote/AuthInterceptor.kt b/app/src/main/java/com/omixlab/lckcontrol/data/remote/AuthInterceptor.kt
index 9a029f6..5235d84 100644
--- a/app/src/main/java/com/omixlab/lckcontrol/data/remote/AuthInterceptor.kt
+++ b/app/src/main/java/com/omixlab/lckcontrol/data/remote/AuthInterceptor.kt
@@ -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")
}
}
diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt b/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt
index 477da24..f4af030 100644
--- a/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt
+++ b/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt
@@ -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
diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/repository/AccountRepository.kt b/app/src/main/java/com/omixlab/lckcontrol/data/repository/AccountRepository.kt
index 3940fbd..2830464 100644
--- a/app/src/main/java/com/omixlab/lckcontrol/data/repository/AccountRepository.kt
+++ b/app/src/main/java/com/omixlab/lckcontrol/data/repository/AccountRepository.kt
@@ -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,
)
}
diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/repository/StreamPlanRepository.kt b/app/src/main/java/com/omixlab/lckcontrol/data/repository/StreamPlanRepository.kt
index 00b454b..e9f27da 100644
--- a/app/src/main/java/com/omixlab/lckcontrol/data/repository/StreamPlanRepository.kt
+++ b/app/src/main/java/com/omixlab/lckcontrol/data/repository/StreamPlanRepository.kt
@@ -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 },
)
},
)
diff --git a/app/src/main/java/com/omixlab/lckcontrol/di/DatabaseModule.kt b/app/src/main/java/com/omixlab/lckcontrol/di/DatabaseModule.kt
index 1e9e316..b8d9cbf 100644
--- a/app/src/main/java/com/omixlab/lckcontrol/di/DatabaseModule.kt
+++ b/app/src/main/java/com/omixlab/lckcontrol/di/DatabaseModule.kt
@@ -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
diff --git a/app/src/main/java/com/omixlab/lckcontrol/service/LckControlService.kt b/app/src/main/java/com/omixlab/lckcontrol/service/LckControlService.kt
index 2db9929..2bb7a4c 100644
--- a/app/src/main/java/com/omixlab/lckcontrol/service/LckControlService.kt
+++ b/app/src/main/java/com/omixlab/lckcontrol/service/LckControlService.kt
@@ -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)
}
diff --git a/app/src/main/java/com/omixlab/lckcontrol/streaming/NativeStreamingEngine.kt b/app/src/main/java/com/omixlab/lckcontrol/streaming/NativeStreamingEngine.kt
index 433b627..bcc0c1f 100644
--- a/app/src/main/java/com/omixlab/lckcontrol/streaming/NativeStreamingEngine.kt
+++ b/app/src/main/java/com/omixlab/lckcontrol/streaming/NativeStreamingEngine.kt
@@ -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)
}
diff --git a/app/src/main/java/com/omixlab/lckcontrol/streaming/StreamingManager.kt b/app/src/main/java/com/omixlab/lckcontrol/streaming/StreamingManager.kt
index b285713..b3c6ba5 100644
--- a/app/src/main/java/com/omixlab/lckcontrol/streaming/StreamingManager.kt
+++ b/app/src/main/java/com/omixlab/lckcontrol/streaming/StreamingManager.kt
@@ -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()
+ }
}
diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsScreen.kt
index a2f7cf2..29e7d54 100644
--- a/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsScreen.kt
+++ b/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsScreen.kt
@@ -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)) }
}
}
diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsViewModel.kt
index cbf086f..00ec595 100644
--- a/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsViewModel.kt
+++ b/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsViewModel.kt
@@ -37,6 +37,22 @@ class AccountsViewModel @Inject constructor(
private val _linkError = MutableStateFlow(null)
val linkError: StateFlow = _linkError.asStateFlow()
+ // Custom RTMP dialog state
+ private val _showCustomRtmpDialog = MutableStateFlow(false)
+ val showCustomRtmpDialog: StateFlow = _showCustomRtmpDialog.asStateFlow()
+
+ private val _customRtmpName = MutableStateFlow("")
+ val customRtmpName: StateFlow = _customRtmpName.asStateFlow()
+
+ private val _customRtmpUrl = MutableStateFlow("")
+ val customRtmpUrl: StateFlow = _customRtmpUrl.asStateFlow()
+
+ private val _customRtmpKey = MutableStateFlow("")
+ val customRtmpKey: StateFlow = _customRtmpKey.asStateFlow()
+
+ private val _isCreatingCustomRtmp = MutableStateFlow(false)
+ val isCreatingCustomRtmp: StateFlow = _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
}
diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardScreen.kt
index 6bc1f84..d8c6316 100644
--- a/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardScreen.kt
+++ b/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardScreen.kt
@@ -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()) {
diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt
index f49fff1..558db72 100644
--- a/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt
+++ b/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt
@@ -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> = streamPlanRepository.observePlans()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
+ val streamingState: StateFlow = streamingManager.state
+ val streamingStats: StateFlow = streamingManager.stats
+ val streamingManagerInstance: StreamingManager = streamingManager
+
private val _backendHealthy = MutableStateFlow(null)
val backendHealthy: StateFlow = _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) {}
+ }
+ }
+ }
}
diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanScreen.kt
index 40812d0..57fc106 100644
--- a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanScreen.kt
+++ b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanScreen.kt
@@ -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,
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanViewModel.kt
index 764815e..a9967a8 100644
--- a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanViewModel.kt
+++ b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanViewModel.kt
@@ -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)
diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailScreen.kt
index 991851e..01d9e95 100644
--- a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailScreen.kt
+++ b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailScreen.kt
@@ -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))
diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailViewModel.kt
index 10f509e..bb1a58d 100644
--- a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailViewModel.kt
+++ b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailViewModel.kt
@@ -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"
diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/StreamPreviewSurface.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/StreamPreviewSurface.kt
new file mode 100644
index 0000000..d9618b4
--- /dev/null
+++ b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/StreamPreviewSurface.kt
@@ -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)),
+ )
+}
diff --git a/app/src/main/java/com/omixlab/lckcontrol/util/GameInfoProvider.kt b/app/src/main/java/com/omixlab/lckcontrol/util/GameInfoProvider.kt
index 5377f70..03b91e6 100644
--- a/app/src/main/java/com/omixlab/lckcontrol/util/GameInfoProvider.kt
+++ b/app/src/main/java/com/omixlab/lckcontrol/util/GameInfoProvider.kt
@@ -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)
+ }
}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..b10844f
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1063 @@
+{
+ "name": "lck-control",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "lck-control",
+ "version": "1.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "node-media-server": "^4.2.4"
+ }
+ },
+ "node_modules/@types/jsonwebtoken": {
+ "version": "9.0.10",
+ "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
+ "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "25.3.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
+ "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.4",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
+ "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.14.0",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/cors": {
+ "version": "2.8.6",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
+ "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.3",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.14.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-jwt": {
+ "version": "8.5.1",
+ "resolved": "https://registry.npmjs.org/express-jwt/-/express-jwt-8.5.1.tgz",
+ "integrity": "sha512-Dv6QjDLpR2jmdb8M6XQXiCcpEom7mK8TOqnr0/TngDKsG2DHVkO8+XnVxkJVN7BuS1I3OrGw6N8j5DaaGgkDRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/jsonwebtoken": "^9",
+ "express-unless": "^2.1.3",
+ "jsonwebtoken": "^9.0.0"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ }
+ },
+ "node_modules/express-unless": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/express-unless/-/express-unless-2.1.3.tgz",
+ "integrity": "sha512-wj4tLMyCVYuIIKHGt0FhCtIViBcwzWejX0EjNxveAa6dG+0XBCQhMbx+PnkLkFCxLC69qoFrxds4pIyL88inaQ==",
+ "license": "MIT"
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/node-media-server": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/node-media-server/-/node-media-server-4.2.4.tgz",
+ "integrity": "sha512-/63DCUJh9BiBfJgCkfYcvQrhjP4vaY7Dh1VGo1PKLwyrudayIg5TcYIU5CkmUctpqoBeYJtQbBUI53xi7UDf2Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "cors": "^2.8.5",
+ "express": "^4.21.2",
+ "express-jwt": "^8.5.1",
+ "jsonwebtoken": "^9.0.3",
+ "ws": "^8.18.3"
+ },
+ "bin": {
+ "node-media-server": "bin/app.js"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.12",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
+ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
+ "license": "MIT"
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.14.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
+ "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "license": "MIT"
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..7f30961
--- /dev/null
+++ b/package.json
@@ -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"
+ }
+}
diff --git a/shared/src/main/java/com/omixlab/lckcontrol/shared/LinkedAccount.kt b/shared/src/main/java/com/omixlab/lckcontrol/shared/LinkedAccount.kt
index 0f925ad..880f92b 100644
--- a/shared/src/main/java/com/omixlab/lckcontrol/shared/LinkedAccount.kt
+++ b/shared/src/main/java/com/omixlab/lckcontrol/shared/LinkedAccount.kt
@@ -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
diff --git a/tools/rtmp-server.js b/tools/rtmp-server.js
new file mode 100644
index 0000000..f71c0c4
--- /dev/null
+++ b/tools/rtmp-server.js
@@ -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://: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');