From c632e22033329d3e5babe09654899c3ba8cecdc9 Mon Sep 17 00:00:00 2001 From: omigamedev Date: Sun, 1 Mar 2026 10:50:23 +0100 Subject: [PATCH] Custom RTMP saved accounts, RTMP test server, composition pipeline - Backend: POST /providers/accounts/custom-rtmp to save reusable RTMP servers - Backend: Encrypt rtmpUrl/streamKey in existing token fields, decrypt on GET - Backend: Skip token revocation on DELETE for CUSTOM_RTMP accounts - Backend: Decrypt CUSTOM_RTMP credentials into destinations on plan create/update - Android: Add rtmpUrl/streamKey to LinkedAccount entity + shared parcelable (Room v6) - Android: Add Custom RTMP dialog in AccountsScreen, auto-fill in plan destination picker - Android: Handle CUSTOM_RTMP accounts in CreatePlanViewModel.loadExistingPlan - Add local RTMP test server (tools/rtmp-server.js) with auto-ffplay on publish - Add composition pipeline native code --- .gitignore | 3 + app/src/main/AndroidManifest.xml | 8 + app/src/main/cpp/CMakeLists.txt | 1 + app/src/main/cpp/composition_pipeline.cpp | 433 +++++++ app/src/main/cpp/composition_pipeline.h | 108 ++ app/src/main/cpp/egl_context.cpp | 50 + app/src/main/cpp/egl_context.h | 25 + app/src/main/cpp/jni_bridge.cpp | 82 ++ app/src/main/cpp/streaming_engine.cpp | 272 ++++- app/src/main/cpp/streaming_engine.h | 63 +- .../lckcontrol/data/local/LckDatabase.kt | 9 +- .../data/local/entity/LinkedAccountEntity.kt | 2 + .../lckcontrol/data/remote/ApiModels.kt | 13 +- .../lckcontrol/data/remote/AuthInterceptor.kt | 24 + .../lckcontrol/data/remote/LckApiService.kt | 3 + .../data/repository/AccountRepository.kt | 11 + .../data/repository/StreamPlanRepository.kt | 8 +- .../omixlab/lckcontrol/di/DatabaseModule.kt | 2 +- .../lckcontrol/service/LckControlService.kt | 19 +- .../streaming/NativeStreamingEngine.kt | 65 + .../lckcontrol/streaming/StreamingManager.kt | 60 + .../lckcontrol/ui/accounts/AccountsScreen.kt | 73 +- .../ui/accounts/AccountsViewModel.kt | 52 + .../ui/dashboard/DashboardScreen.kt | 47 +- .../ui/dashboard/DashboardViewModel.kt | 17 + .../lckcontrol/ui/plans/CreatePlanScreen.kt | 135 ++- .../ui/plans/CreatePlanViewModel.kt | 53 +- .../lckcontrol/ui/plans/PlanDetailScreen.kt | 23 +- .../ui/plans/PlanDetailViewModel.kt | 19 +- .../ui/plans/StreamPreviewSurface.kt | 56 + .../lckcontrol/util/GameInfoProvider.kt | 15 +- package-lock.json | 1063 +++++++++++++++++ package.json | 18 + .../lckcontrol/shared/LinkedAccount.kt | 6 + tools/rtmp-server.js | 82 ++ 35 files changed, 2822 insertions(+), 98 deletions(-) create mode 100644 app/src/main/cpp/composition_pipeline.cpp create mode 100644 app/src/main/cpp/composition_pipeline.h create mode 100644 app/src/main/java/com/omixlab/lckcontrol/ui/plans/StreamPreviewSurface.kt create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tools/rtmp-server.js 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');