Compare commits

...

10 Commits

Author SHA1 Message Date
8fd9f5815a Add P2P module, TLS LAN server, boot receiver, and encoded frame callback
- P2P: NSD advertiser, LAN TLS signaling server (port 8765), WebRTC peer
  manager, remote signaling client, control/file channel handlers
- LAN auth-pair endpoint generates pairing code via OkHttp (with auto
  token refresh) for phone app auto-discovery login
- Shared self-signed certificate (lck_lan.p12) for secure LAN comms
- Service starts at app launch and on BOOT_COMPLETED via BootReceiver
- P2P session waits for auto-login before starting NSD/signaling
- Native encoder: encoded frame callback for H.264 passthrough to WebRTC
- WebRTC dependency switched to io.github.webrtc-sdk (Maven Central)
2026-03-04 14:40:39 +01:00
b235eabd40 Per-stream visibility: isPublic field, Room migration 6→7, publish toggle in CreatePlan 2026-03-03 21:37:20 +01:00
ec1b84994b Cortex: background gameplay recording with chunked .seg segments
Continuously records gameplay in the background when a game is connected.
Keeps the last N minutes (configurable) as ~30s self-contained segment files
on disk, with auto-trimming and thumbnail generation per segment.

C++ CortexRecorder writes .seg binary format in real-time, integrated into
StreamingEngine alongside existing ClipRecorder. StreamingManager routes
frames to cortex-only engine when not streaming, and enables cortex on the
streaming engine during live streams for seamless coexistence.

New Cortex tab in bottom navigation with enable toggle, duration presets,
storage usage, and session list with thumbnails.
2026-03-03 21:35:57 +01:00
d44fe488bd Pairing code generation in Accounts tab, portal chat support
- Add PairingCodeResponse, PairingStatusResponse models + API endpoints
- Add pairing code card to Accounts screen with countdown + auto-dismiss on redeem
- Add Portal service color/label to chat and dashboard live section
- Move portal visibility toggle to dashboard, add UpdateProfileRequest
2026-03-02 23:07:33 +01:00
8b9c18637a Dashboard live section with chat + browser buttons, cleanup drafts, resilient delete
- Replace Live Chat section with unified Live section showing per-destination
  cards with chat button (unread badge) and open-in-browser button
- Show error placeholder when a service fails to connect
- Cleanup button now deletes both ENDED and DRAFT plans
- deletePlan handles 404 gracefully (still removes from Room DB)
- Expose chat connection status and account display names in ViewModel
2026-03-02 09:40:13 +01:00
870139b054 YouTube/Twitch live chat integration
- WebSocket chat client with auto-reconnect and re-subscribe on connect
- ChatScreen with message bubbles, mod/broadcaster badges, send capability
- Dashboard Live Chat section with unread badges per destination
- Chat notification manager for background notifications
- Chat navigation route and ViewModel
2026-03-01 22:19:17 +01:00
ef221ca132 Fix streaming pipeline: timestamps, buffer release, resolution, orientation
- Fix encoder PTS: use wall-clock relative timestamps to prevent backward
  jumps when transitioning from standby to game frames (MediaCodec drops)
- Suppress standby frames while game is active (500ms timeout) to prevent
  flickering between game video and color bars
- Remove standby color bar pattern, use plain dark background
- Fix vertical flip and BGR→RGB swizzle in composition base pass shader
- Pass buffer index through native pipeline for pool slot release callback
- Start engine for already-LIVE plans when APP_STREAMING mode is active
- Use texture pool dimensions for encoder resolution instead of hardcoded 1920x1080
2026-03-01 14:33:57 +01:00
c632e22033 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
2026-03-01 10:50:23 +01:00
c1ff5351b7 Dashboard settings, game icons, default execution mode, fix native lib linking
- Add AppPreferences for persisted default streaming mode (IN_GAME/APP_STREAMING)
- Add GameInfoProvider to resolve package names to icons via PackageManager
- Add GameInfoRow composable used across dashboard, plans, and clients screens
- Show backend version in dashboard status card
- Default execution mode toggle on dashboard, picked up by new plans
- Plan edit mode with full CRUD support
- Fix CMake IMPORTED_NO_SONAME to prevent absolute Windows paths in DT_NEEDED
- Catch Throwable (not just Exception) for UnsatisfiedLinkError in streaming start
2026-02-28 22:38:54 +01:00
097cd24ea9 App streaming pipeline, dashboard server status, account enable/disable, game-linked plans
- Add C++ native streaming engine (RTMP client, EGL context, streaming engine, JNI bridge)
- Add pre-built arm64-v8a libs (librtmp, libssl, libcrypto, libz) and headers
- Add Kotlin streaming layer (NativeStreamingEngine, StreamingManager, StreamingStats)
- Add AIDL streaming interface (ILckStreamingService, ILckStreamingCallback, StreamingConfig)
- Add LckStreamingServiceImpl with BIND_STREAMING action support
- Add APP_STREAMING execution mode with auto-start/stop on plan lifecycle
- SDK: add bindStreaming(), submitVideoFrame(), submitAudioFrame() to LckControlClient
- Dashboard: replace linked accounts with server status card, move health polling from nav
- Remove health check dot overlay from Dashboard nav icon
- Accounts: add enable/disable toggle per account (persists locally, excluded from default plans)
- Plans: add gameId field linked to game package ID, resolved from ClientTracker for default plans
- Service: pass executionMode+gameId through createStreamPlan, filter enabled accounts in createDefaultPlan
- Room DB migration 4→5: add isEnabled column to linked_accounts, gameId column to stream_plans
- Add docs (hub vs control comparison)
2026-02-28 20:05:21 +01:00
104 changed files with 21527 additions and 173 deletions

3
.gitignore vendored
View File

@@ -22,3 +22,6 @@ ovr-platform-util.exe
# Build counter
.buildcount
/.claude
# Tools
node_modules/

View File

@@ -60,9 +60,20 @@ android {
buildConfigField("String", "DISPLAY_VERSION", "\"${gitDisplayVersion()}\"")
ndk {
abiFilters += listOf("arm64-v8a")
}
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
buildTypes {
debug {
signingConfig = signingConfigs.getByName("release")
@@ -143,6 +154,9 @@ dependencies {
// Browser (Custom Tabs for OAuth flows)
implementation(libs.androidx.browser)
// WebRTC (P2P communication with phone app)
implementation("io.github.webrtc-sdk:android:137.7151.05")
// Test
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)

View File

@@ -6,6 +6,17 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Allow querying game packages for icon/label resolution -->
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
<application
android:name=".LckControlApp"
@@ -69,8 +80,19 @@
<intent-filter>
<action android:name="com.omixlab.lckcontrol.BIND" />
</intent-filter>
<intent-filter>
<action android:name="com.omixlab.lckcontrol.BIND_STREAMING" />
</intent-filter>
</service>
<receiver
android:name=".service.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -0,0 +1,58 @@
cmake_minimum_required(VERSION 3.22.1)
project(lck_streaming)
find_library(log-lib log)
find_library(android-lib android)
find_library(mediandk-lib mediandk)
find_library(egl-lib EGL)
find_library(glesv3-lib GLESv3)
find_library(nativewindow-lib nativewindow)
add_library(lck_streaming SHARED
jni_bridge.cpp
rtmp_client.cpp
rtmp_sink.cpp
egl_context.cpp
composition_pipeline.cpp
streaming_engine.cpp
clip_recorder.cpp
cortex_recorder.cpp
faststart.cpp
)
target_include_directories(lck_streaming PRIVATE
${CMAKE_SOURCE_DIR}/third_party/librtmp/include
)
# Import pre-built librtmp from jniLibs
# IMPORTED_NO_SONAME prevents CMake from embedding the absolute build path
# as DT_NEEDED — the Android linker will find the .so by name in the APK.
add_library(rtmp SHARED IMPORTED)
set_target_properties(rtmp PROPERTIES
IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../jniLibs/arm64-v8a/librtmp.so
IMPORTED_NO_SONAME TRUE
)
add_library(ssl SHARED IMPORTED)
set_target_properties(ssl PROPERTIES
IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../jniLibs/arm64-v8a/libssl.so
IMPORTED_NO_SONAME TRUE
)
add_library(crypto SHARED IMPORTED)
set_target_properties(crypto PROPERTIES
IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../jniLibs/arm64-v8a/libcrypto.so
IMPORTED_NO_SONAME TRUE
)
target_link_libraries(lck_streaming
${log-lib}
${android-lib}
${mediandk-lib}
${egl-lib}
${glesv3-lib}
${nativewindow-lib}
rtmp
ssl
crypto
)

View File

@@ -0,0 +1,313 @@
#include "clip_recorder.h"
#include "faststart.h"
#include <media/NdkMediaMuxer.h>
#include <media/NdkMediaFormat.h>
#include <media/NdkMediaCodec.h>
#include <android/log.h>
#include <algorithm>
#include <fcntl.h>
#include <unistd.h>
#include <cstdio>
#define TAG "ClipRecorder"
#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__)
void ClipRecorder::Configure(int width, int height,
uint32_t audioSampleRate, uint32_t audioChannels_,
int audioBitrate_) {
std::lock_guard<std::mutex> lock(mutex);
videoWidth = width;
videoHeight = height;
sampleRate = audioSampleRate;
audioChannels = audioChannels_;
audioBitrate = audioBitrate_;
LOGI("Configured: %dx%d, audio %uHz %uch %dbps", width, height,
audioSampleRate, audioChannels_, audioBitrate_);
}
void ClipRecorder::SetVideoFormat(const uint8_t* codecConfig, uint32_t size) {
std::lock_guard<std::mutex> lock(mutex);
videoCodecConfig.assign(codecConfig, codecConfig + size);
LOGI("Video codec config set: %u bytes", size);
}
void ClipRecorder::SetAudioFormat(const uint8_t* codecConfig, uint32_t size) {
std::lock_guard<std::mutex> lock(mutex);
audioCodecConfig.assign(codecConfig, codecConfig + size);
LOGI("Audio codec config set: %u bytes", size);
}
void ClipRecorder::Start() {
std::lock_guard<std::mutex> lock(mutex);
gopBuffer.clear();
currentGop = GopBuffer();
audioBuffer.clear();
active = true;
LOGI("Started");
}
void ClipRecorder::Stop() {
std::lock_guard<std::mutex> lock(mutex);
active = false;
gopBuffer.clear();
currentGop = GopBuffer();
audioBuffer.clear();
videoCodecConfig.clear();
audioCodecConfig.clear();
LOGI("Stopped");
}
void ClipRecorder::FeedVideoPacket(const uint8_t* data, uint32_t size,
int64_t timestampUs, bool isKeyframe) {
std::lock_guard<std::mutex> lock(mutex);
if (!active) return;
if (isKeyframe) {
// Finalize current GOP and push to buffer
if (!currentGop.samples.empty()) {
gopBuffer.push_back(std::move(currentGop));
}
currentGop = GopBuffer();
currentGop.startTimeUs = timestampUs;
}
VideoSample sample;
sample.data.assign(data, data + size);
sample.timestampUs = timestampUs;
sample.isKeyframe = isKeyframe;
currentGop.samples.push_back(std::move(sample));
TrimBuffers();
}
void ClipRecorder::FeedAudioPacket(const uint8_t* data, uint32_t size,
int64_t timestampUs) {
std::lock_guard<std::mutex> lock(mutex);
if (!active) return;
AudioSample sample;
sample.data.assign(data, data + size);
sample.timestampUs = timestampUs;
audioBuffer.push_back(std::move(sample));
TrimBuffers();
}
void ClipRecorder::TrimBuffers() {
// Trim oldest GOPs to stay under MAX_BUFFER_DURATION_US
if (gopBuffer.size() < 2) return;
int64_t newestTs = 0;
if (!currentGop.samples.empty()) {
newestTs = currentGop.samples.back().timestampUs;
} else if (!gopBuffer.empty() && !gopBuffer.back().samples.empty()) {
newestTs = gopBuffer.back().samples.back().timestampUs;
}
if (newestTs == 0) return;
while (!gopBuffer.empty()) {
int64_t oldestTs = gopBuffer.front().startTimeUs;
if (newestTs - oldestTs > MAX_BUFFER_DURATION_US && gopBuffer.size() > 1) {
gopBuffer.pop_front();
} else {
break;
}
}
// Trim audio samples older than oldest remaining video
if (!gopBuffer.empty()) {
int64_t videoStart = gopBuffer.front().startTimeUs;
while (!audioBuffer.empty() && audioBuffer.front().timestampUs < videoStart) {
audioBuffer.pop_front();
}
}
}
bool ClipRecorder::FlushClip(const std::string& outputDir) {
// Copy data under lock
std::vector<GopBuffer> gopsCopy;
std::vector<AudioSample> audioCopy;
std::vector<uint8_t> videoCsdCopy;
std::vector<uint8_t> audioCsdCopy;
int w, h;
uint32_t sr, ch;
int abr;
{
std::lock_guard<std::mutex> lock(mutex);
if (!active) return false;
if (gopBuffer.empty()) {
LOGW("FlushClip: no complete GOPs");
return false;
}
if (videoCodecConfig.empty()) {
LOGW("FlushClip: no video codec config");
return false;
}
gopsCopy.assign(gopBuffer.begin(), gopBuffer.end());
audioCopy.assign(audioBuffer.begin(), audioBuffer.end());
videoCsdCopy = videoCodecConfig;
audioCsdCopy = audioCodecConfig;
w = videoWidth;
h = videoHeight;
sr = sampleRate;
ch = audioChannels;
abr = audioBitrate;
}
// Mux to temp file
std::string tempPath = outputDir + "/preview_clip_tmp.mp4";
std::string finalPath = outputDir + "/preview_clip.mp4";
int fd = open(tempPath.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
LOGE("FlushClip: failed to open temp file: %s", tempPath.c_str());
return false;
}
AMediaMuxer* muxer = AMediaMuxer_new(fd, AMEDIAMUXER_OUTPUT_FORMAT_MPEG_4);
if (!muxer) {
LOGE("FlushClip: failed to create muxer");
close(fd);
return false;
}
// Video track — split Annex-B SPS+PPS into separate csd-0 (SPS) and csd-1 (PPS)
// MediaCodec CODEC_CONFIG output: [00 00 00 01 SPS] [00 00 00 01 PPS]
// AMediaMuxer expects csd-0 = SPS NAL (with start code), csd-1 = PPS NAL (with start code)
const uint8_t* csd = videoCsdCopy.data();
size_t csdSize = videoCsdCopy.size();
size_t spsLen = csdSize;
size_t ppsOffset = 0;
size_t ppsLen = 0;
// Find the second start code (00 00 00 01) to split SPS from PPS
for (size_t i = 4; i + 3 < csdSize; i++) {
if (csd[i] == 0x00 && csd[i+1] == 0x00 && csd[i+2] == 0x00 && csd[i+3] == 0x01) {
spsLen = i;
ppsOffset = i;
ppsLen = csdSize - i;
break;
}
}
LOGI("FlushClip: csd total=%zu, SPS=%zu bytes, PPS=%zu bytes", csdSize, spsLen, ppsLen);
AMediaFormat* videoFormat = AMediaFormat_new();
AMediaFormat_setString(videoFormat, AMEDIAFORMAT_KEY_MIME, "video/avc");
AMediaFormat_setInt32(videoFormat, AMEDIAFORMAT_KEY_WIDTH, w);
AMediaFormat_setInt32(videoFormat, AMEDIAFORMAT_KEY_HEIGHT, h);
AMediaFormat_setBuffer(videoFormat, "csd-0", csd, spsLen);
if (ppsLen > 0) {
AMediaFormat_setBuffer(videoFormat, "csd-1", csd + ppsOffset, ppsLen);
}
ssize_t videoTrack = AMediaMuxer_addTrack(muxer, videoFormat);
AMediaFormat_delete(videoFormat);
if (videoTrack < 0) {
LOGE("FlushClip: failed to add video track");
AMediaMuxer_delete(muxer);
close(fd);
return false;
}
// Audio track (optional — may not have audio config)
ssize_t audioTrack = -1;
if (!audioCsdCopy.empty()) {
AMediaFormat* audioFormat = AMediaFormat_new();
AMediaFormat_setString(audioFormat, AMEDIAFORMAT_KEY_MIME, "audio/mp4a-latm");
AMediaFormat_setInt32(audioFormat, AMEDIAFORMAT_KEY_SAMPLE_RATE, sr);
AMediaFormat_setInt32(audioFormat, AMEDIAFORMAT_KEY_CHANNEL_COUNT, ch);
AMediaFormat_setInt32(audioFormat, AMEDIAFORMAT_KEY_BIT_RATE, abr);
AMediaFormat_setBuffer(audioFormat, "csd-0", audioCsdCopy.data(), audioCsdCopy.size());
audioTrack = AMediaMuxer_addTrack(muxer, audioFormat);
AMediaFormat_delete(audioFormat);
if (audioTrack < 0) {
LOGW("FlushClip: failed to add audio track, continuing video-only");
audioTrack = -1;
}
}
media_status_t status = AMediaMuxer_start(muxer);
if (status != AMEDIA_OK) {
LOGE("FlushClip: failed to start muxer: %d", status);
AMediaMuxer_delete(muxer);
close(fd);
return false;
}
// Compute base timestamp for zero-based output
int64_t baseTs = gopsCopy.front().startTimeUs;
// Write video samples
int videoSamplesWritten = 0;
for (const auto& gop : gopsCopy) {
for (const auto& sample : gop.samples) {
AMediaCodecBufferInfo info;
info.offset = 0;
info.size = static_cast<int32_t>(sample.data.size());
info.presentationTimeUs = sample.timestampUs - baseTs;
info.flags = sample.isKeyframe ? AMEDIACODEC_BUFFER_FLAG_KEY_FRAME : 0;
AMediaMuxer_writeSampleData(muxer, videoTrack,
sample.data.data(), &info);
videoSamplesWritten++;
}
}
// Write audio samples
int audioSamplesWritten = 0;
if (audioTrack >= 0) {
for (const auto& sample : audioCopy) {
// Only include audio within the video time range
int64_t relTs = sample.timestampUs - baseTs;
if (relTs < 0) continue;
AMediaCodecBufferInfo info;
info.offset = 0;
info.size = static_cast<int32_t>(sample.data.size());
info.presentationTimeUs = relTs;
info.flags = 0;
AMediaMuxer_writeSampleData(muxer, audioTrack,
sample.data.data(), &info);
audioSamplesWritten++;
}
}
AMediaMuxer_stop(muxer);
AMediaMuxer_delete(muxer);
close(fd);
LOGI("FlushClip: muxed %d video + %d audio samples to temp file",
videoSamplesWritten, audioSamplesWritten);
// Apply faststart (move moov atom to front)
if (!MoovFastStart(tempPath, finalPath)) {
LOGW("FlushClip: faststart failed, using non-optimized file");
rename(tempPath.c_str(), finalPath.c_str());
} else {
unlink(tempPath.c_str());
}
LOGI("FlushClip: clip ready at %s", finalPath.c_str());
if (clipReadyCallback) {
clipReadyCallback(finalPath);
}
return true;
}
void ClipRecorder::SetClipReadyCallback(ClipReadyCallback cb) {
std::lock_guard<std::mutex> lock(mutex);
clipReadyCallback = std::move(cb);
}

View File

@@ -0,0 +1,75 @@
#pragma once
#include <cstdint>
#include <deque>
#include <functional>
#include <mutex>
#include <string>
#include <vector>
struct VideoSample {
std::vector<uint8_t> data;
int64_t timestampUs;
bool isKeyframe;
};
struct AudioSample {
std::vector<uint8_t> data;
int64_t timestampUs;
};
struct GopBuffer {
std::vector<VideoSample> samples;
int64_t startTimeUs = 0;
};
class ClipRecorder {
public:
using ClipReadyCallback = std::function<void(const std::string& path)>;
ClipRecorder() = default;
~ClipRecorder() = default;
void Configure(int width, int height,
uint32_t audioSampleRate, uint32_t audioChannels,
int audioBitrate);
void SetVideoFormat(const uint8_t* codecConfig, uint32_t size);
void SetAudioFormat(const uint8_t* codecConfig, uint32_t size);
void Start();
void Stop();
void FeedVideoPacket(const uint8_t* data, uint32_t size,
int64_t timestampUs, bool isKeyframe);
void FeedAudioPacket(const uint8_t* data, uint32_t size,
int64_t timestampUs);
bool FlushClip(const std::string& outputDir);
void SetClipReadyCallback(ClipReadyCallback cb);
private:
void TrimBuffers();
std::mutex mutex;
bool active = false;
int videoWidth = 0;
int videoHeight = 0;
uint32_t sampleRate = 48000;
uint32_t audioChannels = 2;
int audioBitrate = 128000;
std::vector<uint8_t> videoCodecConfig;
std::vector<uint8_t> audioCodecConfig;
std::deque<GopBuffer> gopBuffer;
GopBuffer currentGop;
std::deque<AudioSample> audioBuffer;
ClipReadyCallback clipReadyCallback;
static constexpr int64_t MAX_BUFFER_DURATION_US = 12000000; // 12s
};

View File

@@ -0,0 +1,435 @@
#include "composition_pipeline.h"
#include <android/log.h>
#include <algorithm>
#include <cstring>
#define TAG "LckComposition"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
// --- Shaders ---
static const char* BASE_VERTEX_SHADER = R"(#version 300 es
layout(location = 0) in vec2 aPos;
layout(location = 1) in vec2 aTexCoord;
out vec2 vTexCoord;
void main() {
gl_Position = vec4(aPos, 0.0, 1.0);
vTexCoord = aTexCoord;
}
)";
// Base pass: renders game frame (OES texture) full-screen to FBO
// - Flips V coordinate: Vulkan render targets have origin at top-left, GL at bottom-left
// - Swizzles BGR→RGB: Vulkan RGBA8 maps to BGRA in some AHardwareBuffer formats
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() {
vec2 flippedCoord = vec2(vTexCoord.x, 1.0 - vTexCoord.y);
vec4 color = texture(uTexture, flippedCoord);
fragColor = vec4(color.b, color.g, color.r, color.a);
}
)";
// 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 leave cleared dark background if no game input)
if (srcOesTexture != 0) {
RenderBasePass(srcOesTexture);
}
// Overlay pass: render layers sorted by zOrder
RenderOverlayLayers();
glBindFramebuffer(GL_FRAMEBUFFER, 0);
}
void CompositionPipeline::RenderBasePass(GLuint srcOesTexture) {
glUseProgram(baseProgram);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_EXTERNAL_OES, srcOesTexture);
glUniform1i(glGetUniformLocation(baseProgram, "uTexture"), 0);
glBindVertexArray(quadVao);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
}
void CompositionPipeline::RenderStandbyPattern() {
glUseProgram(standbyProgram);
glBindVertexArray(quadVao);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
}
void CompositionPipeline::RenderOverlayLayers() {
// Take a snapshot of layers under lock
std::vector<CompositionLayer> snapshot;
{
std::lock_guard<std::mutex> lock(layerMutex);
snapshot = layers;
}
// Sort by zOrder
std::sort(snapshot.begin(), snapshot.end(),
[](const CompositionLayer& a, const CompositionLayer& b) {
return a.zOrder < b.zOrder;
});
// Enable alpha blending
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glUseProgram(overlayProgram);
for (const auto& layer : snapshot) {
if (!layer.enabled || layer.textureId == 0 || layer.opacity <= 0.0f) continue;
float mvp[16];
BuildTransformMatrix(layer, mvp);
glUniformMatrix4fv(overlayMvpLoc, 1, GL_FALSE, mvp);
glUniform1f(overlayOpacityLoc, layer.opacity);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, layer.textureId);
glUniform1i(overlayTexLoc, 0);
glBindVertexArray(quadVao);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
}
glDisable(GL_BLEND);
}
void CompositionPipeline::BuildTransformMatrix(const CompositionLayer& layer, float* m) {
// Build 2D transform matrix in NDC space.
// The quad is [-1,1] full-screen. We need to scale it to layer size
// relative to FBO, then apply position/rotation.
const auto& t = layer.transform;
// Layer size in NDC (layer pixel size / FBO pixel size → fraction → *2 for NDC range)
float layerW = (float)layer.texWidth / (float)fboWidth;
float layerH = (float)layer.texHeight / (float)fboHeight;
float sx = layerW * t.scaleX;
float sy = layerH * t.scaleY;
float cosR = cosf(t.rotation);
float sinR = sinf(t.rotation);
// Build: Translate(pos) * Rotate(r) * Scale(s)
// Column-major 4x4
Mat4Identity(m);
m[0] = sx * cosR;
m[1] = sx * sinR;
m[4] = -sy * sinR;
m[5] = sy * cosR;
m[12] = t.posX;
m[13] = t.posY;
}
int CompositionPipeline::AddLayer(GLuint textureId, int texW, int texH,
const CompositionTransform& transform,
float opacity, int zOrder, const std::string& tag,
bool ownsTexture) {
std::lock_guard<std::mutex> lock(layerMutex);
CompositionLayer layer;
layer.id = nextLayerId++;
layer.textureId = textureId;
layer.texWidth = texW;
layer.texHeight = texH;
layer.transform = transform;
layer.opacity = opacity;
layer.zOrder = zOrder;
layer.tag = tag;
layer.ownsTexture = ownsTexture;
layers.push_back(layer);
LOGI("Added composition layer %d ('%s') z=%d", layer.id, tag.c_str(), zOrder);
return layer.id;
}
void CompositionPipeline::RemoveLayer(int layerId) {
std::lock_guard<std::mutex> lock(layerMutex);
for (auto it = layers.begin(); it != layers.end(); ++it) {
if (it->id == layerId) {
if (it->ownsTexture && it->textureId) {
glDeleteTextures(1, &it->textureId);
}
LOGI("Removed composition layer %d ('%s')", it->id, it->tag.c_str());
layers.erase(it);
return;
}
}
LOGW("RemoveLayer: layer %d not found", layerId);
}
void CompositionPipeline::UpdateLayerTransform(int layerId, const CompositionTransform& transform) {
std::lock_guard<std::mutex> lock(layerMutex);
for (auto& layer : layers) {
if (layer.id == layerId) {
layer.transform = transform;
return;
}
}
}
void CompositionPipeline::UpdateLayerOpacity(int layerId, float opacity) {
std::lock_guard<std::mutex> lock(layerMutex);
for (auto& layer : layers) {
if (layer.id == layerId) {
layer.opacity = opacity;
return;
}
}
}
void CompositionPipeline::SetLayerEnabled(int layerId, bool enabled) {
std::lock_guard<std::mutex> lock(layerMutex);
for (auto& layer : layers) {
if (layer.id == layerId) {
layer.enabled = enabled;
return;
}
}
}
GLuint CompositionPipeline::UploadTexture(const uint8_t* rgbaData, int w, int h) {
if (!rgbaData || w <= 0 || h <= 0) return 0;
GLuint tex;
glGenTextures(1, &tex);
glBindTexture(GL_TEXTURE_2D, tex);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, w, h, 0,
GL_RGBA, GL_UNSIGNED_BYTE, rgbaData);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glBindTexture(GL_TEXTURE_2D, 0);
return tex;
}
void CompositionPipeline::Release() {
// Delete owned layer textures
{
std::lock_guard<std::mutex> lock(layerMutex);
for (auto& layer : layers) {
if (layer.ownsTexture && layer.textureId) {
glDeleteTextures(1, &layer.textureId);
}
}
layers.clear();
}
if (quadVao) { glDeleteVertexArrays(1, &quadVao); quadVao = 0; }
if (quadVbo) { glDeleteBuffers(1, &quadVbo); quadVbo = 0; }
if (baseProgram) { glDeleteProgram(baseProgram); baseProgram = 0; }
if (overlayProgram) { glDeleteProgram(overlayProgram); overlayProgram = 0; }
if (standbyProgram) { glDeleteProgram(standbyProgram); standbyProgram = 0; }
if (fbo) { glDeleteFramebuffers(1, &fbo); fbo = 0; }
if (fboTexture) { glDeleteTextures(1, &fboTexture); fboTexture = 0; }
initialized = false;
LOGI("Composition pipeline released");
}

View File

@@ -0,0 +1,108 @@
#pragma once
#include <GLES3/gl3.h>
#include <GLES2/gl2ext.h>
#include <cmath>
#include <mutex>
#include <string>
#include <vector>
struct CompositionTransform {
float posX = 0.0f; // NDC [-1, 1]
float posY = 0.0f;
float scaleX = 1.0f;
float scaleY = 1.0f;
float rotation = 0.0f; // radians
float anchorX = 0.5f; // [0, 1] within layer
float anchorY = 0.5f;
};
struct CompositionLayer {
int id = -1;
GLuint textureId = 0; // GL_TEXTURE_2D
int texWidth = 0;
int texHeight = 0;
CompositionTransform transform;
float opacity = 1.0f;
bool enabled = true;
int zOrder = 0;
std::string tag;
bool ownsTexture = true; // if true, pipeline deletes texture on removal
};
/**
* GPU composition pipeline.
* Renders a base OES texture (game frame) plus overlay layers to an FBO,
* producing a GL_TEXTURE_2D that can be blit to encoder and preview surfaces.
*/
class CompositionPipeline {
public:
CompositionPipeline();
~CompositionPipeline();
/** Initialize FBO, shaders, and quad geometry at encoder resolution. */
bool Init(int width, int height);
/**
* Compose the scene: render base OES texture + overlay layers to FBO.
* Must be called on the GL thread.
*/
void Compose(GLuint srcOesTexture);
/** Returns the FBO color attachment (GL_TEXTURE_2D). */
GLuint GetComposedTexture() const { return fboTexture; }
/** Layer management — thread-safe. */
int AddLayer(GLuint textureId, int texW, int texH,
const CompositionTransform& transform,
float opacity, int zOrder, const std::string& tag,
bool ownsTexture = true);
void RemoveLayer(int layerId);
void UpdateLayerTransform(int layerId, const CompositionTransform& transform);
void UpdateLayerOpacity(int layerId, float opacity);
void SetLayerEnabled(int layerId, bool enabled);
/** Upload raw RGBA pixels as a GL_TEXTURE_2D. Returns texture ID (0 on failure). */
static GLuint UploadTexture(const uint8_t* rgbaData, int w, int h);
/** Release all GL resources. Must be called on the GL thread. */
void Release();
bool IsInitialized() const { return initialized; }
private:
void RenderBasePass(GLuint srcOesTexture);
void RenderStandbyPattern();
void RenderOverlayLayers();
void BuildTransformMatrix(const CompositionLayer& layer, float* outMat4);
// Standby pattern shader
GLuint standbyProgram = 0;
int fboWidth = 0;
int fboHeight = 0;
bool initialized = false;
// FBO
GLuint fbo = 0;
GLuint fboTexture = 0; // RGBA8 color attachment
// Shaders
GLuint baseProgram = 0; // samplerExternalOES for game frame
GLuint overlayProgram = 0; // sampler2D + uMVP + uOpacity for layers
// Shared quad VAO
GLuint quadVao = 0;
GLuint quadVbo = 0;
// Overlay uniform locations
GLint overlayMvpLoc = -1;
GLint overlayOpacityLoc = -1;
GLint overlayTexLoc = -1;
// Layers (protected by mutex)
std::mutex layerMutex;
std::vector<CompositionLayer> layers;
int nextLayerId = 1;
};

View File

@@ -0,0 +1,328 @@
#include "cortex_recorder.h"
#include <android/log.h>
#include <dirent.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
#include <algorithm>
#include <cstdio>
#define TAG "CortexRecorder"
#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__)
// Segment binary format magic
static const char SEGMENT_MAGIC[4] = {'C', 'S', 'E', 'G'};
static const uint32_t SEGMENT_VERSION = 1;
static const uint32_t END_MARKER = 0xFFFFFFFF;
// Helper: write raw bytes to fd
static bool writeBytes(int fd, const void* data, size_t size) {
return write(fd, data, size) == static_cast<ssize_t>(size);
}
template<typename T>
static bool writeVal(int fd, T val) {
return writeBytes(fd, &val, sizeof(T));
}
void CortexRecorder::Configure(int w, int h, uint32_t sr, uint32_t ch, int abr) {
std::lock_guard<std::mutex> lock(mutex);
videoWidth = w;
videoHeight = h;
sampleRate = sr;
audioChannels = ch;
audioBitrate = abr;
LOGI("Configured: %dx%d, audio %uHz %uch %dbps", w, h, sr, ch, abr);
}
void CortexRecorder::SetVideoFormat(const uint8_t* sps_pps, uint32_t size) {
std::lock_guard<std::mutex> lock(mutex);
videoCodecConfig.assign(sps_pps, sps_pps + size);
LOGI("Video format set: %u bytes", size);
}
void CortexRecorder::SetAudioFormat(const uint8_t* aac_config, uint32_t size) {
std::lock_guard<std::mutex> lock(mutex);
audioCodecConfig.assign(aac_config, aac_config + size);
LOGI("Audio format set: %u bytes", size);
}
void CortexRecorder::StartSession(const std::string& dir) {
std::lock_guard<std::mutex> lock(mutex);
// Create directory
mkdir(dir.c_str(), 0755);
sessionDir = dir;
segmentIndex = 0;
segmentStartUs = 0;
currentSamples.clear();
firstKeyframeData.clear();
active = true;
LOGI("Session started: %s", dir.c_str());
}
void CortexRecorder::StopSession() {
std::lock_guard<std::mutex> lock(mutex);
if (!active) return;
// Finalize any partial segment
if (!currentSamples.empty()) {
FinalizeCurrentSegment();
}
active = false;
currentSamples.clear();
firstKeyframeData.clear();
videoCodecConfig.clear();
audioCodecConfig.clear();
LOGI("Session stopped");
}
void CortexRecorder::FeedVideoPacket(const uint8_t* data, uint32_t size,
int64_t timestampUs, bool isKeyframe) {
std::lock_guard<std::mutex> lock(mutex);
if (!active) return;
// On keyframe boundary, check if we should finalize the current segment
if (isKeyframe && !currentSamples.empty()) {
int64_t segDuration = timestampUs - segmentStartUs;
if (segDuration >= SEGMENT_DURATION_US) {
FinalizeCurrentSegment();
}
}
// Start new segment if empty
if (currentSamples.empty()) {
segmentStartUs = timestampUs;
}
// Store first keyframe data for thumbnail callback
if (isKeyframe && firstKeyframeData.empty()) {
firstKeyframeData.assign(data, data + size);
}
CortexSampleEntry entry;
entry.track = 0; // video
entry.flags = isKeyframe ? 1 : 0;
entry.size = size;
entry.timestampUs = timestampUs;
entry.data.assign(data, data + size);
currentSamples.push_back(std::move(entry));
}
void CortexRecorder::FeedAudioPacket(const uint8_t* data, uint32_t size,
int64_t timestampUs) {
std::lock_guard<std::mutex> lock(mutex);
if (!active) return;
CortexSampleEntry entry;
entry.track = 1; // audio
entry.flags = 0;
entry.size = size;
entry.timestampUs = timestampUs;
entry.data.assign(data, data + size);
currentSamples.push_back(std::move(entry));
}
void CortexRecorder::SetMaxDurationMinutes(int minutes) {
std::lock_guard<std::mutex> lock(mutex);
maxDurationMinutes = minutes;
LOGI("Max duration set to %d minutes", minutes);
}
void CortexRecorder::SetSegmentCallback(SegmentCallback cb) {
std::lock_guard<std::mutex> lock(mutex);
segmentCallback = std::move(cb);
}
bool CortexRecorder::IsActive() const {
return active;
}
std::string CortexRecorder::MakeSegmentPath(int index) const {
char buf[64];
snprintf(buf, sizeof(buf), "seg_%06d.seg", index);
return sessionDir + "/" + buf;
}
void CortexRecorder::FinalizeCurrentSegment() {
// Must be called under lock
if (currentSamples.empty()) return;
std::string segPath = MakeSegmentPath(segmentIndex);
int fd = open(segPath.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
LOGE("Failed to open segment file: %s", segPath.c_str());
currentSamples.clear();
firstKeyframeData.clear();
return;
}
// Compute duration
int64_t lastTs = currentSamples.back().timestampUs;
int64_t segDuration = lastTs - segmentStartUs;
uint32_t sampleCount = static_cast<uint32_t>(currentSamples.size());
// Write header
writeBytes(fd, SEGMENT_MAGIC, 4);
writeVal<uint32_t>(fd, SEGMENT_VERSION);
writeVal<uint32_t>(fd, static_cast<uint32_t>(videoWidth));
writeVal<uint32_t>(fd, static_cast<uint32_t>(videoHeight));
// SPS/PPS
uint32_t spsPpsSize = static_cast<uint32_t>(videoCodecConfig.size());
writeVal<uint32_t>(fd, spsPpsSize);
if (spsPpsSize > 0) writeBytes(fd, videoCodecConfig.data(), spsPpsSize);
// AAC config
uint32_t aacConfigSize = static_cast<uint32_t>(audioCodecConfig.size());
writeVal<uint32_t>(fd, aacConfigSize);
if (aacConfigSize > 0) writeBytes(fd, audioCodecConfig.data(), aacConfigSize);
// Timing
writeVal<int64_t>(fd, segmentStartUs);
writeVal<int64_t>(fd, segDuration);
writeVal<uint32_t>(fd, sampleCount);
// Write samples
for (const auto& sample : currentSamples) {
writeVal<uint8_t>(fd, sample.track);
writeVal<uint8_t>(fd, sample.flags);
writeVal<uint32_t>(fd, sample.size);
writeVal<int64_t>(fd, sample.timestampUs);
writeBytes(fd, sample.data.data(), sample.size);
}
// End marker
writeVal<uint32_t>(fd, END_MARKER);
close(fd);
LOGI("Segment finalized: %s (%u samples, %.1fs)",
segPath.c_str(), sampleCount, segDuration / 1000000.0);
// Callback with segment path + first keyframe data for thumbnail
if (segmentCallback && !firstKeyframeData.empty()) {
// Copy callback and data before calling (callback may be slow)
auto cb = segmentCallback;
auto kfData = firstKeyframeData;
// Call outside lock would be better, but we're already under lock.
// The callback should be fast (just posts to another thread).
cb(segPath, kfData.data(), static_cast<uint32_t>(kfData.size()));
}
segmentIndex++;
currentSamples.clear();
firstKeyframeData.clear();
// Trim old segments
TrimOldSegments();
}
void CortexRecorder::TrimOldSegments() {
// Must be called under lock
// Scan all .seg files in session dir, read their duration from header,
// and delete oldest when total exceeds maxDurationMinutes
struct SegInfo {
std::string path;
int64_t durationUs;
int index;
};
DIR* dir = opendir(sessionDir.c_str());
if (!dir) return;
std::vector<SegInfo> segments;
struct dirent* entry;
while ((entry = readdir(dir)) != nullptr) {
std::string name = entry->d_name;
if (name.size() < 4 || name.substr(name.size() - 4) != ".seg") continue;
std::string path = sessionDir + "/" + name;
// Parse index from filename (seg_NNNNNN.seg)
int idx = 0;
if (name.size() >= 14 && name.substr(0, 4) == "seg_") {
idx = std::atoi(name.substr(4, 6).c_str());
}
// Read duration from header
int fd = open(path.c_str(), O_RDONLY);
if (fd < 0) continue;
// Skip: magic(4) + version(4) + width(4) + height(4) = 16
// Then sps_pps_size(4) + N bytes, aac_config_size(4) + N bytes
// Then seg_start_us(8), seg_duration_us(8)
char magic[4];
if (read(fd, magic, 4) != 4 || memcmp(magic, SEGMENT_MAGIC, 4) != 0) {
close(fd);
continue;
}
uint32_t ver, w, h, spsSz, aacSz;
read(fd, &ver, 4);
read(fd, &w, 4);
read(fd, &h, 4);
read(fd, &spsSz, 4);
lseek(fd, spsSz, SEEK_CUR);
read(fd, &aacSz, 4);
lseek(fd, aacSz, SEEK_CUR);
int64_t startUs, durationUs;
read(fd, &startUs, 8);
read(fd, &durationUs, 8);
close(fd);
segments.push_back({path, durationUs, idx});
}
closedir(dir);
if (segments.empty()) return;
// Sort by index (ascending = oldest first)
std::sort(segments.begin(), segments.end(),
[](const SegInfo& a, const SegInfo& b) { return a.index < b.index; });
// Sum total duration from newest backward
int64_t maxUs = static_cast<int64_t>(maxDurationMinutes) * 60 * 1000000LL;
int64_t totalUs = 0;
// Walk from newest to oldest, mark keep boundary
int keepFrom = static_cast<int>(segments.size()); // index into segments where we start keeping
for (int i = static_cast<int>(segments.size()) - 1; i >= 0; i--) {
totalUs += segments[i].durationUs;
if (totalUs > maxUs) {
// This segment pushes us over — delete from here backward
break;
}
keepFrom = i;
}
// Delete segments before keepFrom
int deleted = 0;
for (int i = 0; i < keepFrom; i++) {
unlink(segments[i].path.c_str());
// Also delete associated thumbnail
std::string thumbPath = sessionDir + "/";
char thumbName[64];
snprintf(thumbName, sizeof(thumbName), "thumb_%06d.jpg", segments[i].index);
thumbPath += thumbName;
unlink(thumbPath.c_str());
deleted++;
}
if (deleted > 0) {
LOGI("Trimmed %d old segments (total duration %.0fs, max %dm)",
deleted, totalUs / 1000000.0, maxDurationMinutes);
}
}

View File

@@ -0,0 +1,69 @@
#pragma once
#include <cstdint>
#include <functional>
#include <mutex>
#include <string>
#include <vector>
struct CortexSampleEntry {
uint8_t track; // 0=video, 1=audio
uint8_t flags; // bit0=keyframe
uint32_t size;
int64_t timestampUs;
std::vector<uint8_t> data;
};
class CortexRecorder {
public:
using SegmentCallback = std::function<void(const std::string& segPath,
const uint8_t* keyframeData,
uint32_t keyframeSize)>;
CortexRecorder() = default;
~CortexRecorder() = default;
void Configure(int w, int h, uint32_t sampleRate, uint32_t channels, int audioBitrate);
void SetVideoFormat(const uint8_t* sps_pps, uint32_t size);
void SetAudioFormat(const uint8_t* aac_config, uint32_t size);
void StartSession(const std::string& sessionDir);
void StopSession();
void FeedVideoPacket(const uint8_t* data, uint32_t size, int64_t timestampUs, bool isKeyframe);
void FeedAudioPacket(const uint8_t* data, uint32_t size, int64_t timestampUs);
void SetMaxDurationMinutes(int minutes);
void SetSegmentCallback(SegmentCallback cb);
bool IsActive() const;
private:
void FinalizeCurrentSegment();
void TrimOldSegments();
std::string MakeSegmentPath(int index) const;
std::mutex mutex;
bool active = false;
int videoWidth = 0;
int videoHeight = 0;
uint32_t sampleRate = 48000;
uint32_t audioChannels = 2;
int audioBitrate = 128000;
std::vector<uint8_t> videoCodecConfig; // SPS+PPS
std::vector<uint8_t> audioCodecConfig; // AAC config
std::string sessionDir;
int segmentIndex = 0;
int64_t segmentStartUs = 0;
std::vector<CortexSampleEntry> currentSamples;
std::vector<uint8_t> firstKeyframeData; // first keyframe NAL in current segment
int maxDurationMinutes = 10;
static constexpr int64_t SEGMENT_DURATION_US = 30000000LL; // 30 seconds
SegmentCallback segmentCallback;
};

View File

@@ -0,0 +1,269 @@
#include "egl_context.h"
#include <android/log.h>
#include <android/native_window.h>
#include <unistd.h>
#define TAG "LckEglContext"
#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__)
#ifndef EGL_NATIVE_BUFFER_ANDROID
#define EGL_NATIVE_BUFFER_ANDROID 0x3140
#endif
#ifndef EGL_SYNC_NATIVE_FENCE_ANDROID
#define EGL_SYNC_NATIVE_FENCE_ANDROID 0x3144
#endif
#ifndef EGL_SYNC_NATIVE_FENCE_FD_ANDROID
#define EGL_SYNC_NATIVE_FENCE_FD_ANDROID 0x3145
#endif
#ifndef EGL_RECORDABLE_ANDROID
#define EGL_RECORDABLE_ANDROID 0x3142
#endif
EglContext::EglContext() {}
EglContext::~EglContext() {
Release();
}
bool EglContext::LoadExtensions() {
eglCreateSyncKHR = (PFNEGLCREATESYNCKHRPROC)eglGetProcAddress("eglCreateSyncKHR");
eglWaitSyncKHR = (PFNEGLWAITSYNCKHRPROC)eglGetProcAddress("eglWaitSyncKHR");
eglDestroySyncKHR = (PFNEGLDESTROYSYNCKHRPROC)eglGetProcAddress("eglDestroySyncKHR");
eglGetNativeClientBufferANDROID = (PFNEGLGETNATIVECLIENTBUFFERANDROIDPROC)eglGetProcAddress("eglGetNativeClientBufferANDROID");
eglCreateImageKHR = (PFNEGLCREATEIMAGEKHRPROC)eglGetProcAddress("eglCreateImageKHR");
eglDestroyImageKHR = (PFNEGLDESTROYIMAGEKHRPROC)eglGetProcAddress("eglDestroyImageKHR");
glEGLImageTargetTexture2DOES = (PFNGLEGLIMAGETARGETTEXTURE2DOESPROC)eglGetProcAddress("glEGLImageTargetTexture2DOES");
eglPresentationTimeANDROID = (PFNEGLPRESENTATIONTIMEANDROIDPROC)eglGetProcAddress("eglPresentationTimeANDROID");
if (!eglGetNativeClientBufferANDROID || !eglCreateImageKHR ||
!eglDestroyImageKHR || !glEGLImageTargetTexture2DOES) {
LOGE("Missing required EGL extensions for HardwareBuffer import");
return false;
}
return true;
}
bool EglContext::Init() {
display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if (display == EGL_NO_DISPLAY) {
LOGE("eglGetDisplay failed");
return false;
}
EGLint major, minor;
if (!eglInitialize(display, &major, &minor)) {
LOGE("eglInitialize failed");
return false;
}
LOGI("EGL initialized: %d.%d", major, minor);
// EGL config: RGBA8, ES3, recordable for MediaCodec
EGLint configAttribs[] = {
EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT,
EGL_RED_SIZE, 8,
EGL_GREEN_SIZE, 8,
EGL_BLUE_SIZE, 8,
EGL_ALPHA_SIZE, 8,
EGL_SURFACE_TYPE, EGL_WINDOW_BIT,
EGL_RECORDABLE_ANDROID, EGL_TRUE,
EGL_NONE
};
EGLint numConfigs;
if (!eglChooseConfig(display, configAttribs, &config, 1, &numConfigs) || numConfigs == 0) {
LOGE("eglChooseConfig failed");
return false;
}
EGLint contextAttribs[] = {
EGL_CONTEXT_CLIENT_VERSION, 3,
EGL_NONE
};
context = eglCreateContext(display, config, EGL_NO_CONTEXT, contextAttribs);
if (context == EGL_NO_CONTEXT) {
LOGE("eglCreateContext failed");
return false;
}
if (!LoadExtensions()) {
return false;
}
LOGI("EGL context created successfully");
return true;
}
bool EglContext::CreateWindowSurface(ANativeWindow* window) {
if (surface != EGL_NO_SURFACE) {
eglDestroySurface(display, surface);
}
surface = eglCreateWindowSurface(display, config, window, nullptr);
if (surface == EGL_NO_SURFACE) {
LOGE("eglCreateWindowSurface failed: 0x%x", eglGetError());
return false;
}
eglQuerySurface(display, surface, EGL_WIDTH, &surfaceWidth);
eglQuerySurface(display, surface, EGL_HEIGHT, &surfaceHeight);
LOGI("EGL window surface created: %dx%d", surfaceWidth, surfaceHeight);
return true;
}
GLuint EglContext::ImportHardwareBuffer(AHardwareBuffer* buffer) {
if (!eglGetNativeClientBufferANDROID || !eglCreateImageKHR || !glEGLImageTargetTexture2DOES) {
LOGE("Missing EGL extensions for HardwareBuffer import");
return 0;
}
EGLClientBuffer clientBuffer = eglGetNativeClientBufferANDROID(buffer);
if (!clientBuffer) {
LOGE("eglGetNativeClientBufferANDROID failed");
return 0;
}
EGLint imageAttribs[] = {
EGL_IMAGE_PRESERVED_KHR, EGL_TRUE,
EGL_NONE
};
EGLImageKHR image = eglCreateImageKHR(display, EGL_NO_CONTEXT,
EGL_NATIVE_BUFFER_ANDROID,
clientBuffer, imageAttribs);
if (image == EGL_NO_IMAGE_KHR) {
LOGE("eglCreateImageKHR failed: 0x%x", eglGetError());
return 0;
}
GLuint textureId;
glGenTextures(1, &textureId);
glBindTexture(GL_TEXTURE_EXTERNAL_OES, textureId);
glEGLImageTargetTexture2DOES(GL_TEXTURE_EXTERNAL_OES, image);
// We need to keep the image alive — store it associated with the texture
// The caller must call ReleaseImportedTexture to clean up
// For now, we destroy the image immediately since the texture retains the content
eglDestroyImageKHR(display, image);
return textureId;
}
void EglContext::ReleaseImportedTexture(GLuint textureId, EGLImageKHR image) {
if (textureId) {
glDeleteTextures(1, &textureId);
}
if (image != EGL_NO_IMAGE_KHR && eglDestroyImageKHR) {
eglDestroyImageKHR(display, image);
}
}
void EglContext::WaitFence(int fenceFd) {
if (fenceFd < 0) return;
if (eglCreateSyncKHR && eglWaitSyncKHR && eglDestroySyncKHR) {
EGLint attribs[] = {
EGL_SYNC_NATIVE_FENCE_FD_ANDROID, fenceFd,
EGL_NONE
};
EGLSyncKHR sync = eglCreateSyncKHR(display, EGL_SYNC_NATIVE_FENCE_ANDROID, attribs);
if (sync != EGL_NO_SYNC_KHR) {
// GPU-side wait — doesn't block CPU
eglWaitSyncKHR(display, sync, 0);
eglDestroySyncKHR(display, sync);
// eglCreateSyncKHR takes ownership of fenceFd
return;
}
}
// Fallback: CPU-side wait
close(fenceFd);
}
void EglContext::SetPresentationTime(int64_t timestampNs) {
if (eglPresentationTimeANDROID && surface != EGL_NO_SURFACE) {
eglPresentationTimeANDROID(display, surface, timestampNs);
}
}
bool EglContext::MakeCurrent() {
return eglMakeCurrent(display, surface, surface, context) == EGL_TRUE;
}
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;
}
if (context != EGL_NO_CONTEXT) {
eglDestroyContext(display, context);
context = EGL_NO_CONTEXT;
}
eglTerminate(display);
display = EGL_NO_DISPLAY;
}
LOGI("EGL resources released");
}

View File

@@ -0,0 +1,94 @@
#pragma once
#include <EGL/egl.h>
#include <EGL/eglext.h>
#include <GLES3/gl3.h>
#include <GLES2/gl2ext.h>
#include <android/hardware_buffer.h>
/**
* EGL context for importing HardwareBuffers and blitting to encoder Surface.
* Handles EGL setup, HardwareBuffer→EGLImage→texture import, and fence sync.
*/
class EglContext {
public:
EglContext();
~EglContext();
/** Initialize EGL with a recordable config. Returns true on success. */
bool Init();
/** Create a window surface from an ANativeWindow (encoder input surface). */
bool CreateWindowSurface(ANativeWindow* window);
/** Import a HardwareBuffer as a GL texture. Returns texture ID (0 on failure). */
GLuint ImportHardwareBuffer(AHardwareBuffer* buffer);
/** Release a previously imported HardwareBuffer texture. */
void ReleaseImportedTexture(GLuint textureId, EGLImageKHR image);
/** Wait on a native GPU fence FD. Takes ownership of the FD. */
void WaitFence(int fenceFd);
/** Set presentation time on the current surface. */
void SetPresentationTime(int64_t timestampNs);
/** Make the window surface current. */
bool MakeCurrent();
/** 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();
EGLDisplay GetDisplay() const { return display; }
int GetWidth() const { return surfaceWidth; }
int GetHeight() const { return surfaceHeight; }
private:
EGLDisplay display = EGL_NO_DISPLAY;
EGLContext context = EGL_NO_CONTEXT;
EGLSurface surface = EGL_NO_SURFACE;
EGLConfig config = nullptr;
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;
PFNEGLDESTROYSYNCKHRPROC eglDestroySyncKHR = nullptr;
PFNEGLGETNATIVECLIENTBUFFERANDROIDPROC eglGetNativeClientBufferANDROID = nullptr;
PFNEGLCREATEIMAGEKHRPROC eglCreateImageKHR = nullptr;
PFNEGLDESTROYIMAGEKHRPROC eglDestroyImageKHR = nullptr;
PFNGLEGLIMAGETARGETTEXTURE2DOESPROC glEGLImageTargetTexture2DOES = nullptr;
PFNEGLPRESENTATIONTIMEANDROIDPROC eglPresentationTimeANDROID = nullptr;
bool LoadExtensions();
};

View File

@@ -0,0 +1,235 @@
#include "faststart.h"
#include <android/log.h>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <vector>
#define TAG "MoovFastStart"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
static uint32_t ReadBE32(FILE* f) {
uint8_t buf[4];
if (fread(buf, 1, 4, f) != 4) return 0;
return (uint32_t(buf[0]) << 24) | (uint32_t(buf[1]) << 16) |
(uint32_t(buf[2]) << 8) | uint32_t(buf[3]);
}
static void WriteBE32(uint8_t* p, uint32_t v) {
p[0] = (v >> 24) & 0xFF;
p[1] = (v >> 16) & 0xFF;
p[2] = (v >> 8) & 0xFF;
p[3] = v & 0xFF;
}
static void WriteBE64(uint8_t* p, uint64_t v) {
WriteBE32(p, (uint32_t)(v >> 32));
WriteBE32(p + 4, (uint32_t)(v & 0xFFFFFFFF));
}
static uint64_t ReadBE64(const uint8_t* p) {
return ((uint64_t)((uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) |
(uint32_t(p[2]) << 8) | uint32_t(p[3])) << 32) |
((uint32_t(p[4]) << 24) | (uint32_t(p[5]) << 16) |
(uint32_t(p[6]) << 8) | uint32_t(p[7]));
}
// Adjust chunk offsets in moov by a delta (moov size)
static void AdjustChunkOffsets(uint8_t* data, uint32_t size, int64_t delta) {
if (size < 8) return;
uint32_t atomSize = (uint32_t(data[0]) << 24) | (uint32_t(data[1]) << 16) |
(uint32_t(data[2]) << 8) | uint32_t(data[3]);
char fourcc[5] = {(char)data[4], (char)data[5], (char)data[6], (char)data[7], 0};
if (atomSize == 0 || atomSize > size) atomSize = size;
if (strcmp(fourcc, "stco") == 0 && atomSize >= 16) {
// 32-bit chunk offset box: 4 size + 4 fourcc + 1 version + 3 flags + 4 count + N*4 offsets
uint32_t count = (uint32_t(data[12]) << 24) | (uint32_t(data[13]) << 16) |
(uint32_t(data[14]) << 8) | uint32_t(data[15]);
for (uint32_t i = 0; i < count && (16 + (i + 1) * 4) <= atomSize; i++) {
uint8_t* p = data + 16 + i * 4;
uint32_t offset = (uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) |
(uint32_t(p[2]) << 8) | uint32_t(p[3]);
WriteBE32(p, (uint32_t)(offset + delta));
}
} else if (strcmp(fourcc, "co64") == 0 && atomSize >= 16) {
// 64-bit chunk offset box
uint32_t count = (uint32_t(data[12]) << 24) | (uint32_t(data[13]) << 16) |
(uint32_t(data[14]) << 8) | uint32_t(data[15]);
for (uint32_t i = 0; i < count && (16 + (i + 1) * 8) <= atomSize; i++) {
uint8_t* p = data + 16 + i * 8;
uint64_t offset = ReadBE64(p);
WriteBE64(p, offset + delta);
}
} else {
// Container atom — recurse into children
// Skip header (8 bytes for regular, 16 for version+flags atoms)
bool isContainer = (strcmp(fourcc, "moov") == 0 || strcmp(fourcc, "trak") == 0 ||
strcmp(fourcc, "mdia") == 0 || strcmp(fourcc, "minf") == 0 ||
strcmp(fourcc, "stbl") == 0 || strcmp(fourcc, "edts") == 0 ||
strcmp(fourcc, "dinf") == 0 || strcmp(fourcc, "udta") == 0);
if (isContainer) {
uint32_t offset = 8;
while (offset + 8 <= atomSize) {
uint32_t childSize = (uint32_t(data[offset]) << 24) | (uint32_t(data[offset+1]) << 16) |
(uint32_t(data[offset+2]) << 8) | uint32_t(data[offset+3]);
if (childSize < 8 || offset + childSize > atomSize) break;
AdjustChunkOffsets(data + offset, childSize, delta);
offset += childSize;
}
}
}
}
static bool CopyBytes(FILE* src, FILE* dst, int64_t count) {
uint8_t buf[8192];
while (count > 0) {
size_t toRead = (count > (int64_t)sizeof(buf)) ? sizeof(buf) : (size_t)count;
size_t read = fread(buf, 1, toRead, src);
if (read != toRead) return false;
if (fwrite(buf, 1, read, dst) != read) return false;
count -= read;
}
return true;
}
bool MoovFastStart(const std::string& inputPath, const std::string& outputPath) {
FILE* input = fopen(inputPath.c_str(), "rb");
if (!input) {
LOGE("Cannot open input: %s", inputPath.c_str());
return false;
}
// Scan for atoms
struct Atom { char fourcc[5]; int64_t offset; int64_t size; };
std::vector<Atom> atoms;
fseek(input, 0, SEEK_END);
int64_t fileSize = ftell(input);
fseek(input, 0, SEEK_SET);
int64_t pos = 0;
while (pos < fileSize) {
fseek(input, pos, SEEK_SET);
uint32_t size32 = ReadBE32(input);
uint32_t fourcc_raw = ReadBE32(input);
Atom atom;
atom.fourcc[0] = (fourcc_raw >> 24) & 0xFF;
atom.fourcc[1] = (fourcc_raw >> 16) & 0xFF;
atom.fourcc[2] = (fourcc_raw >> 8) & 0xFF;
atom.fourcc[3] = fourcc_raw & 0xFF;
atom.fourcc[4] = 0;
atom.offset = pos;
if (size32 == 1) {
// 64-bit extended size
uint8_t ext[8];
if (fread(ext, 1, 8, input) != 8) break;
atom.size = (int64_t)ReadBE64(ext);
} else if (size32 == 0) {
atom.size = fileSize - pos;
} else {
atom.size = size32;
}
if (atom.size < 8) break;
atoms.push_back(atom);
pos += atom.size;
}
// Find moov and mdat
int moovIdx = -1, mdatIdx = -1;
for (int i = 0; i < (int)atoms.size(); i++) {
if (strcmp(atoms[i].fourcc, "moov") == 0) moovIdx = i;
if (strcmp(atoms[i].fourcc, "mdat") == 0) mdatIdx = i;
}
if (moovIdx < 0) {
LOGE("No moov atom found");
fclose(input);
return false;
}
if (moovIdx < mdatIdx || mdatIdx < 0) {
// moov already before mdat — just copy as-is
LOGI("moov already at front, copying file");
fclose(input);
if (inputPath != outputPath) {
FILE* in2 = fopen(inputPath.c_str(), "rb");
FILE* out = fopen(outputPath.c_str(), "wb");
if (!in2 || !out) {
if (in2) fclose(in2);
if (out) fclose(out);
return false;
}
bool ok = CopyBytes(in2, out, fileSize);
fclose(in2);
fclose(out);
return ok;
}
return true;
}
// Read moov into memory
int64_t moovSize = atoms[moovIdx].size;
std::vector<uint8_t> moovData(moovSize);
fseek(input, atoms[moovIdx].offset, SEEK_SET);
if (fread(moovData.data(), 1, moovSize, input) != (size_t)moovSize) {
LOGE("Failed to read moov atom");
fclose(input);
return false;
}
// Adjust chunk offsets by moov size (since moov will be inserted before mdat)
AdjustChunkOffsets(moovData.data(), moovData.size(), moovSize);
// Write output: atoms before mdat + moov + mdat + atoms after moov
FILE* output = fopen(outputPath.c_str(), "wb");
if (!output) {
LOGE("Cannot open output: %s", outputPath.c_str());
fclose(input);
return false;
}
// Write all atoms before mdat
for (int i = 0; i < mdatIdx; i++) {
fseek(input, atoms[i].offset, SEEK_SET);
if (!CopyBytes(input, output, atoms[i].size)) {
LOGE("Failed to copy pre-mdat atom");
fclose(input);
fclose(output);
return false;
}
}
// Write moov (with adjusted offsets)
if (fwrite(moovData.data(), 1, moovSize, output) != (size_t)moovSize) {
LOGE("Failed to write moov");
fclose(input);
fclose(output);
return false;
}
// Write mdat and everything after (except moov which we already wrote)
for (int i = mdatIdx; i < (int)atoms.size(); i++) {
if (i == moovIdx) continue; // skip moov — already written
fseek(input, atoms[i].offset, SEEK_SET);
if (!CopyBytes(input, output, atoms[i].size)) {
LOGE("Failed to copy post-moov atom");
fclose(input);
fclose(output);
return false;
}
}
fclose(input);
fclose(output);
LOGI("Faststart complete: moov moved to front");
return true;
}

View File

@@ -0,0 +1,12 @@
#pragma once
#include <string>
/**
* Moves the moov atom from the end of an MP4 file to the beginning,
* enabling instant playback in web browsers without buffering.
*
* inputPath and outputPath may be different files.
* Returns true if moov was successfully relocated (or was already at front).
*/
bool MoovFastStart(const std::string& inputPath, const std::string& outputPath);

View File

@@ -0,0 +1,348 @@
#include "streaming_engine.h"
#include <jni.h>
#include <android/hardware_buffer_jni.h>
#include <android/native_window_jni.h>
#include <android/log.h>
#define TAG "LckJniBridge"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
static JavaVM* gJavaVM = nullptr;
// Cache for callback method IDs
static jmethodID gOnStatsMethod = nullptr;
static jmethodID gOnErrorMethod = nullptr;
static jmethodID gOnBufferReleasedMethod = nullptr;
static jmethodID gOnClipReadyMethod = nullptr;
static jmethodID gOnCortexSegmentMethod = nullptr;
static jmethodID gOnEncodedFrameMethod = nullptr;
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
gJavaVM = vm;
return JNI_VERSION_1_6;
}
extern "C" {
JNIEXPORT jlong JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeCreate(
JNIEnv* env, jobject thiz,
jint width, jint height,
jint videoBitrate, jint audioBitrate,
jint sampleRate, jint channels,
jint keyframeInterval) {
auto* engine = new StreamingEngine();
engine->Configure(width, height, videoBitrate, audioBitrate,
sampleRate, channels, keyframeInterval);
// Set up callbacks that call back into Kotlin
jobject globalRef = env->NewGlobalRef(thiz);
// Cache method IDs
jclass cls = env->GetObjectClass(thiz);
gOnStatsMethod = env->GetMethodID(cls, "onNativeStats", "(JJII)V");
gOnErrorMethod = env->GetMethodID(cls, "onNativeError", "(ILjava/lang/String;)V");
gOnBufferReleasedMethod = env->GetMethodID(cls, "onNativeBufferReleased", "(I)V");
gOnClipReadyMethod = env->GetMethodID(cls, "onNativeClipReady", "(Ljava/lang/String;)V");
gOnCortexSegmentMethod = env->GetMethodID(cls, "onNativeCortexSegment", "(Ljava/lang/String;[B)V");
gOnEncodedFrameMethod = env->GetMethodID(cls, "onNativeEncodedFrame", "([BZJ)V");
engine->SetEncodedFrameCallback([globalRef](const uint8_t* data, size_t size,
bool isKeyFrame, int64_t timestampUs) {
JNIEnv* env;
if (gJavaVM->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
if (gJavaVM->AttachCurrentThread(&env, nullptr) != JNI_OK) return;
}
if (gOnEncodedFrameMethod) {
jbyteArray jdata = env->NewByteArray(size);
env->SetByteArrayRegion(jdata, 0, size,
reinterpret_cast<const jbyte*>(data));
env->CallVoidMethod(globalRef, gOnEncodedFrameMethod,
jdata, (jboolean)(isKeyFrame ? JNI_TRUE : JNI_FALSE),
(jlong)timestampUs);
env->DeleteLocalRef(jdata);
}
});
engine->SetCortexSegmentCallback([globalRef](const std::string& segPath,
const uint8_t* keyframeData,
uint32_t keyframeSize) {
JNIEnv* env;
if (gJavaVM->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
if (gJavaVM->AttachCurrentThread(&env, nullptr) != JNI_OK) return;
}
if (gOnCortexSegmentMethod) {
jstring jpath = env->NewStringUTF(segPath.c_str());
jbyteArray jdata = env->NewByteArray(keyframeSize);
env->SetByteArrayRegion(jdata, 0, keyframeSize,
reinterpret_cast<const jbyte*>(keyframeData));
env->CallVoidMethod(globalRef, gOnCortexSegmentMethod, jpath, jdata);
env->DeleteLocalRef(jpath);
env->DeleteLocalRef(jdata);
}
});
engine->SetClipReadyCallback([globalRef](const std::string& path) {
JNIEnv* env;
if (gJavaVM->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
if (gJavaVM->AttachCurrentThread(&env, nullptr) != JNI_OK) return;
}
if (gOnClipReadyMethod) {
jstring jpath = env->NewStringUTF(path.c_str());
env->CallVoidMethod(globalRef, gOnClipReadyMethod, jpath);
env->DeleteLocalRef(jpath);
}
});
engine->SetStatsCallback([globalRef](const StreamingStats& stats) {
JNIEnv* env;
if (gJavaVM->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
if (gJavaVM->AttachCurrentThread(&env, nullptr) != JNI_OK) return;
}
if (gOnStatsMethod) {
env->CallVoidMethod(globalRef, gOnStatsMethod,
(jlong)stats.videoBitrate, (jlong)stats.audioBitrate,
(jint)stats.fps, (jint)stats.droppedFrames);
}
});
engine->SetErrorCallback([globalRef](int code, const std::string& message) {
JNIEnv* env;
if (gJavaVM->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
if (gJavaVM->AttachCurrentThread(&env, nullptr) != JNI_OK) return;
}
if (gOnErrorMethod) {
jstring msg = env->NewStringUTF(message.c_str());
env->CallVoidMethod(globalRef, gOnErrorMethod, (jint)code, msg);
env->DeleteLocalRef(msg);
}
});
engine->SetBufferReleasedCallback([globalRef](int bufferIndex) {
JNIEnv* env;
if (gJavaVM->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
if (gJavaVM->AttachCurrentThread(&env, nullptr) != JNI_OK) return;
}
if (gOnBufferReleasedMethod) {
env->CallVoidMethod(globalRef, gOnBufferReleasedMethod, (jint)bufferIndex);
}
});
LOGI("Native engine created: %dx%d", width, height);
return reinterpret_cast<jlong>(engine);
}
JNIEXPORT jint JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeAddDestination(
JNIEnv* env, jobject thiz, jlong ptr, jstring rtmpUrl) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return -1;
const char* url = env->GetStringUTFChars(rtmpUrl, nullptr);
int index = engine->AddDestination(url);
env->ReleaseStringUTFChars(rtmpUrl, url);
return index;
}
JNIEXPORT jboolean JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeStart(
JNIEnv* env, jobject thiz, jlong ptr) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return JNI_FALSE;
return engine->Start() ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT void JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeSubmitVideoFrame(
JNIEnv* env, jobject thiz, jlong ptr,
jobject hardwareBuffer, jlong timestampNs, jint fenceFd, jint bufferIndex) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return;
AHardwareBuffer* buffer = AHardwareBuffer_fromHardwareBuffer(env, hardwareBuffer);
if (!buffer) {
LOGE("Failed to get AHardwareBuffer from Java HardwareBuffer");
return;
}
engine->SubmitVideoFrame(buffer, timestampNs, fenceFd, bufferIndex);
}
JNIEXPORT void JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeSubmitAudioFrame(
JNIEnv* env, jobject thiz, jlong ptr,
jbyteArray pcmData, jlong timestampNs) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return;
jsize len = env->GetArrayLength(pcmData);
jbyte* data = env->GetByteArrayElements(pcmData, nullptr);
engine->SubmitAudioFrame(reinterpret_cast<const uint8_t*>(data), len, timestampNs);
env->ReleaseByteArrayElements(pcmData, data, JNI_ABORT);
}
JNIEXPORT void JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeStop(
JNIEnv* env, jobject thiz, jlong ptr) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return;
engine->Stop();
}
JNIEXPORT void JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeDestroy(
JNIEnv* env, jobject thiz, jlong ptr) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (engine) {
engine->Stop();
delete engine;
LOGI("Native engine destroyed");
}
}
JNIEXPORT jboolean JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeIsRunning(
JNIEnv* env, jobject thiz, jlong ptr) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return JNI_FALSE;
return engine->IsRunning() ? JNI_TRUE : JNI_FALSE;
}
// --- Preview surface ---
JNIEXPORT void JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeSetPreviewSurface(
JNIEnv* env, jobject thiz, jlong ptr, jobject surface) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine || !surface) return;
ANativeWindow* window = ANativeWindow_fromSurface(env, surface);
if (window) {
engine->SetPreviewSurface(window);
ANativeWindow_release(window); // SetPreviewSurface acquires its own ref
}
}
JNIEXPORT void JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeRemovePreviewSurface(
JNIEnv* env, jobject thiz, jlong ptr) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return;
engine->RemovePreviewSurface();
}
// --- Composition layers ---
JNIEXPORT jint JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeAddCompositionLayer(
JNIEnv* env, jobject thiz, jlong ptr,
jbyteArray rgbaData, jint w, jint h,
jfloat posX, jfloat posY, jfloat scaleX, jfloat scaleY,
jfloat rotation, jfloat opacity, jint zOrder, jstring tag) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return -1;
jsize len = env->GetArrayLength(rgbaData);
jbyte* data = env->GetByteArrayElements(rgbaData, nullptr);
const char* tagStr = env->GetStringUTFChars(tag, nullptr);
int layerId = engine->AddCompositionLayer(
reinterpret_cast<const uint8_t*>(data), w, h,
posX, posY, scaleX, scaleY, rotation, opacity, zOrder,
std::string(tagStr));
env->ReleaseStringUTFChars(tag, tagStr);
env->ReleaseByteArrayElements(rgbaData, data, JNI_ABORT);
return layerId;
}
JNIEXPORT void JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeRemoveCompositionLayer(
JNIEnv* env, jobject thiz, jlong ptr, jint layerId) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return;
engine->RemoveCompositionLayer(layerId);
}
JNIEXPORT void JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeUpdateCompositionLayerTransform(
JNIEnv* env, jobject thiz, jlong ptr, jint layerId,
jfloat posX, jfloat posY, jfloat scaleX, jfloat scaleY, jfloat rotation) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return;
engine->UpdateCompositionLayerTransform(layerId, posX, posY, scaleX, scaleY, rotation);
}
JNIEXPORT void JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeUpdateCompositionLayerOpacity(
JNIEnv* env, jobject thiz, jlong ptr, jint layerId, jfloat opacity) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return;
engine->UpdateCompositionLayerOpacity(layerId, opacity);
}
JNIEXPORT void JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeSetCompositionLayerEnabled(
JNIEnv* env, jobject thiz, jlong ptr, jint layerId, jboolean enabled) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return;
engine->SetCompositionLayerEnabled(layerId, enabled == JNI_TRUE);
}
// --- Clip recording ---
JNIEXPORT void JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeEnableClipRecording(
JNIEnv* env, jobject thiz, jlong ptr, jint width, jint height) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return;
engine->EnableClipRecording(width, height);
}
JNIEXPORT jboolean JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeFlushClip(
JNIEnv* env, jobject thiz, jlong ptr, jstring outputDir) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return JNI_FALSE;
const char* dir = env->GetStringUTFChars(outputDir, nullptr);
bool result = engine->FlushClip(dir);
env->ReleaseStringUTFChars(outputDir, dir);
return result ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT void JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeDisableClipRecording(
JNIEnv* env, jobject thiz, jlong ptr) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return;
engine->DisableClipRecording();
}
// --- Cortex recording ---
JNIEXPORT void JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeEnableCortexRecording(
JNIEnv* env, jobject thiz, jlong ptr, jstring sessionDir, jint maxMinutes) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return;
const char* dir = env->GetStringUTFChars(sessionDir, nullptr);
engine->EnableCortexRecording(dir, maxMinutes);
env->ReleaseStringUTFChars(sessionDir, dir);
}
JNIEXPORT void JNICALL
Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeDisableCortexRecording(
JNIEnv* env, jobject thiz, jlong ptr) {
auto* engine = reinterpret_cast<StreamingEngine*>(ptr);
if (!engine) return;
engine->DisableCortexRecording();
}
} // extern "C"

View File

@@ -0,0 +1,177 @@
#include "rtmp_client.h"
#include <android/log.h>
#include <cstring>
extern "C" {
#include <librtmp/rtmp.h>
#include <librtmp/log.h>
}
#define TAG "LckRtmpClient"
#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__)
RtmpClient::RtmpClient() {}
RtmpClient::~RtmpClient() {
Disconnect();
}
bool RtmpClient::Connect(const std::string& rtmpUrl) {
if (connected) {
LOGW("Already connected, disconnecting first");
Disconnect();
}
RTMP_LogSetLevel(RTMP_LOGWARNING);
rtmpContext = RTMP_Alloc();
if (!rtmpContext) {
LOGE("RTMP_Alloc failed");
return false;
}
RTMP_Init(rtmpContext);
// RTMP_SetupURL needs a mutable char*
std::vector<char> urlBuffer(rtmpUrl.begin(), rtmpUrl.end());
urlBuffer.push_back('\0');
if (!RTMP_SetupURL(rtmpContext, urlBuffer.data())) {
LOGE("RTMP_SetupURL failed");
RTMP_Free(rtmpContext);
rtmpContext = nullptr;
return false;
}
RTMP_EnableWrite(rtmpContext);
if (!RTMP_Connect(rtmpContext, nullptr)) {
LOGE("RTMP_Connect failed");
RTMP_Free(rtmpContext);
rtmpContext = nullptr;
return false;
}
if (!RTMP_ConnectStream(rtmpContext, 0)) {
LOGE("RTMP_ConnectStream failed");
RTMP_Close(rtmpContext);
RTMP_Free(rtmpContext);
rtmpContext = nullptr;
return false;
}
connected = true;
LOGI("RTMP connected");
return true;
}
void RtmpClient::Disconnect() {
if (rtmpContext) {
RTMP_Close(rtmpContext);
RTMP_Free(rtmpContext);
rtmpContext = nullptr;
LOGI("RTMP disconnected");
}
connected = false;
}
bool RtmpClient::IsConnected() const {
return connected && rtmpContext && RTMP_IsConnected(rtmpContext);
}
bool RtmpClient::SendRtmpPacket(uint8_t packetType, uint32_t timestampMs, const uint8_t* data, uint32_t size) {
if (!IsConnected())
return false;
RTMPPacket pkt;
RTMPPacket_Alloc(&pkt, size);
pkt.m_packetType = packetType;
pkt.m_nChannel = (packetType == RTMP_PACKET_TYPE_VIDEO) ? 0x06 : 0x07;
pkt.m_headerType = RTMP_PACKET_SIZE_LARGE;
pkt.m_nTimeStamp = timestampMs;
pkt.m_hasAbsTimestamp = 1;
pkt.m_nInfoField2 = rtmpContext->m_stream_id;
pkt.m_nBodySize = size;
memcpy(pkt.m_body, data, size);
int ret = RTMP_SendPacket(rtmpContext, &pkt, 0);
RTMPPacket_Free(&pkt);
if (!ret) {
LOGW("RTMP_SendPacket failed (type=%d, size=%u)", packetType, size);
connected = false;
}
return ret != 0;
}
bool RtmpClient::SendAvcSequenceHeader(const uint8_t* extraData, uint32_t extraDataSize) {
// FLV video tag: keyframe(1) + AVC(7) = 0x17, AVC sequence header = 0x00, composition time = 0
uint32_t bodySize = 5 + extraDataSize;
std::vector<uint8_t> body(bodySize);
body[0] = 0x17; // keyframe + AVC
body[1] = 0x00; // AVC sequence header
body[2] = 0x00; // composition time
body[3] = 0x00;
body[4] = 0x00;
memcpy(body.data() + 5, extraData, extraDataSize);
return SendRtmpPacket(RTMP_PACKET_TYPE_VIDEO, 0, body.data(), bodySize);
}
void RtmpClient::BuildAudioSpecificConfig(uint8_t outConfig[2], uint32_t sampleRate, uint32_t numChannels) {
static const uint32_t sampleRateTable[] = {
96000, 88200, 64000, 48000, 44100, 32000,
24000, 22050, 16000, 12000, 11025, 8000, 7350
};
uint8_t freqIndex = 4; // default 44100
for (uint8_t i = 0; i < 13; ++i) {
if (sampleRateTable[i] == sampleRate) {
freqIndex = i;
break;
}
}
uint8_t channelConfig = static_cast<uint8_t>(numChannels < 1 ? 1 : (numChannels > 7 ? 7 : numChannels));
// Pack: AAAAA FFFF CCCC 000
outConfig[0] = (2 << 3) | (freqIndex >> 1);
outConfig[1] = ((freqIndex & 1) << 7) | (channelConfig << 3);
}
bool RtmpClient::SendAacSequenceHeader(uint32_t sampleRate, uint32_t numChannels) {
// FLV audio tag: AAC(10) + 44100(3) + 16-bit(1) + stereo(1) = 0xAF, AAC sequence header = 0x00
uint8_t body[4];
body[0] = 0xAF;
body[1] = 0x00;
BuildAudioSpecificConfig(body + 2, sampleRate, numChannels);
return SendRtmpPacket(RTMP_PACKET_TYPE_AUDIO, 0, body, sizeof(body));
}
bool RtmpClient::SendVideoPacket(const uint8_t* data, uint32_t size, uint32_t timestampMs, bool isKeyframe) {
uint32_t bodySize = 5 + size;
std::vector<uint8_t> body(bodySize);
body[0] = isKeyframe ? 0x17 : 0x27;
body[1] = 0x01; // AVC NALU
body[2] = 0x00; // composition time offset
body[3] = 0x00;
body[4] = 0x00;
memcpy(body.data() + 5, data, size);
return SendRtmpPacket(RTMP_PACKET_TYPE_VIDEO, timestampMs, body.data(), bodySize);
}
bool RtmpClient::SendAudioPacket(const uint8_t* data, uint32_t size, uint32_t timestampMs) {
uint32_t bodySize = 2 + size;
std::vector<uint8_t> body(bodySize);
body[0] = 0xAF;
body[1] = 0x01; // AAC raw
memcpy(body.data() + 2, data, size);
return SendRtmpPacket(RTMP_PACKET_TYPE_AUDIO, timestampMs, body.data(), bodySize);
}

View File

@@ -0,0 +1,34 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
struct RTMP;
/**
* Low-level librtmp wrapper for RTMP streaming.
* Ported from FLCKRtmpClient (UE5 LCKStreaming plugin).
* All methods should be called from the same thread (encoder thread).
*/
class RtmpClient {
public:
RtmpClient();
~RtmpClient();
bool Connect(const std::string& rtmpUrl);
void Disconnect();
bool IsConnected() const;
bool SendAvcSequenceHeader(const uint8_t* extraData, uint32_t extraDataSize);
bool SendAacSequenceHeader(uint32_t sampleRate, uint32_t numChannels);
bool SendVideoPacket(const uint8_t* data, uint32_t size, uint32_t timestampMs, bool isKeyframe);
bool SendAudioPacket(const uint8_t* data, uint32_t size, uint32_t timestampMs);
private:
bool SendRtmpPacket(uint8_t packetType, uint32_t timestampMs, const uint8_t* data, uint32_t size);
static void BuildAudioSpecificConfig(uint8_t outConfig[2], uint32_t sampleRate, uint32_t numChannels);
RTMP* rtmpContext = nullptr;
bool connected = false;
};

View File

@@ -0,0 +1,278 @@
#include "rtmp_sink.h"
#include <android/log.h>
#include <cstring>
#include <algorithm>
#define TAG "LckRtmpSink"
#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__)
RtmpSink::RtmpSink() {}
RtmpSink::~RtmpSink() {
if (isOpen) {
Close();
}
}
void RtmpSink::SetRtmpUrl(const std::string& url) {
rtmpUrl = url;
}
bool RtmpSink::Open(uint32_t width, uint32_t height, uint32_t framerate,
uint32_t sampleRate, uint32_t numChannels) {
if (rtmpUrl.empty()) {
LOGE("RTMP URL not set");
return false;
}
storedSampleRate = sampleRate;
storedNumChannels = numChannels;
if (!rtmpClient.Connect(rtmpUrl)) {
LOGE("Failed to connect RTMP");
return false;
}
isOpen = true;
videoHeaderSent = false;
audioHeaderSent = false;
LOGI("RTMP sink opened: %dx%d@%dfps, %dHz %dch",
width, height, framerate, sampleRate, numChannels);
return true;
}
void RtmpSink::OnVideoFormatReady(const uint8_t* extraData, uint32_t extraDataSize) {
if (!isOpen) return;
if (extraData && extraDataSize > 0) {
// Check if already AVCC format (starts with version byte 0x01)
if (extraDataSize > 4 && extraData[0] == 0x01) {
if (rtmpClient.SendAvcSequenceHeader(extraData, extraDataSize)) {
videoHeaderSent = true;
LOGI("Sent AVC sequence header (AVCC, %u bytes)", extraDataSize);
}
} else {
// Annex-B format - extract and convert
TryExtractAndSendSequenceHeader(extraData, extraDataSize);
}
}
}
void RtmpSink::OnAudioFormatReady(uint32_t sampleRate, uint32_t numChannels) {
if (!isOpen) return;
storedSampleRate = sampleRate;
storedNumChannels = numChannels;
if (rtmpClient.SendAacSequenceHeader(sampleRate, numChannels)) {
audioHeaderSent = true;
LOGI("Sent AAC sequence header (%dHz, %dch)", sampleRate, numChannels);
}
}
void RtmpSink::SendVideoPacket(const uint8_t* data, uint32_t size,
int64_t timestampMs, bool isKeyframe) {
if (!isOpen || !rtmpClient.IsConnected()) return;
// If we haven't sent the video sequence header yet and this is a keyframe,
// try to extract SPS/PPS from it
if (!videoHeaderSent && isKeyframe) {
TryExtractAndSendSequenceHeader(data, size);
}
if (!videoHeaderSent) return;
// Send AAC sequence header on first video packet if not sent yet
if (!audioHeaderSent) {
if (rtmpClient.SendAacSequenceHeader(storedSampleRate, storedNumChannels)) {
audioHeaderSent = true;
LOGI("Sent AAC sequence header (deferred, %dHz, %dch)",
storedSampleRate, storedNumChannels);
}
}
uint32_t ts = static_cast<uint32_t>(std::max<int64_t>(timestampMs, 0));
// Convert Annex-B to AVCC for RTMP/FLV
std::vector<uint8_t> avccData = ConvertAnnexBToAvcc(data, size);
if (!avccData.empty()) {
rtmpClient.SendVideoPacket(avccData.data(), static_cast<uint32_t>(avccData.size()),
ts, isKeyframe);
}
}
void RtmpSink::SendAudioPacket(const uint8_t* data, uint32_t size, int64_t timestampMs) {
if (!isOpen || !rtmpClient.IsConnected()) return;
if (!audioHeaderSent || !videoHeaderSent) return;
uint32_t ts = static_cast<uint32_t>(std::max<int64_t>(timestampMs, 0));
rtmpClient.SendAudioPacket(data, size, ts);
}
void RtmpSink::Close() {
if (isOpen) {
rtmpClient.Disconnect();
isOpen = false;
videoHeaderSent = false;
audioHeaderSent = false;
LOGI("RTMP sink closed");
}
}
bool RtmpSink::IsOpen() const {
return isOpen;
}
bool RtmpSink::TryExtractAndSendSequenceHeader(const uint8_t* data, uint32_t size) {
// Parse Annex-B bitstream to find SPS and PPS NALUs
const uint8_t* sps = nullptr;
uint32_t spsSize = 0;
const uint8_t* pps = nullptr;
uint32_t ppsSize = 0;
const uint8_t* end = data + size;
auto findStartCode = [](const uint8_t* p, const uint8_t* end) -> const uint8_t* {
while (p + 3 <= end) {
if (p[0] == 0 && p[1] == 0) {
if (p[2] == 1) return p + 3;
if (p + 3 < end && p[2] == 0 && p[3] == 1) return p + 4;
}
p++;
}
return nullptr;
};
const uint8_t* pos = findStartCode(data, end);
while (pos && pos < end) {
uint8_t currentNaluType = pos[0] & 0x1F;
const uint8_t* currentNaluStart = pos;
const uint8_t* nextStart = findStartCode(pos, end);
const uint8_t* naluEnd;
if (nextStart) {
naluEnd = nextStart - 3;
if (naluEnd > data && *(naluEnd - 1) == 0) naluEnd--;
} else {
naluEnd = end;
}
uint32_t naluSize = static_cast<uint32_t>(naluEnd - currentNaluStart);
if (currentNaluType == 7 && !sps) { // SPS
sps = currentNaluStart;
spsSize = naluSize;
} else if (currentNaluType == 8 && !pps) { // PPS
pps = currentNaluStart;
ppsSize = naluSize;
}
if (sps && pps) break;
pos = nextStart;
}
if (sps && spsSize > 0 && pps && ppsSize > 0) {
std::vector<uint8_t> avcc = BuildAvccFromAnnexB(sps, spsSize, pps, ppsSize);
if (rtmpClient.SendAvcSequenceHeader(avcc.data(), static_cast<uint32_t>(avcc.size()))) {
videoHeaderSent = true;
LOGI("Sent AVC sequence header (extracted SPS=%u PPS=%u)", spsSize, ppsSize);
return true;
} else {
LOGE("SendAvcSequenceHeader failed (SPS=%u PPS=%u)", spsSize, ppsSize);
}
}
return false;
}
std::vector<uint8_t> RtmpSink::ConvertAnnexBToAvcc(const uint8_t* data, uint32_t size) {
std::vector<uint8_t> result;
result.reserve(size);
auto findStartCode = [](const uint8_t* p, const uint8_t* end, int& startCodeLen) -> const uint8_t* {
while (p + 3 <= end) {
if (p[0] == 0 && p[1] == 0) {
if (p + 3 < end && p[2] == 0 && p[3] == 1) {
startCodeLen = 4;
return p;
}
if (p[2] == 1) {
startCodeLen = 3;
return p;
}
}
p++;
}
return nullptr;
};
const uint8_t* pos = data;
const uint8_t* end = data + size;
int startCodeLen = 0;
const uint8_t* startCode = findStartCode(pos, end, startCodeLen);
if (!startCode) {
// No start codes found - pass through
result.insert(result.end(), data, data + size);
return result;
}
while (startCode) {
const uint8_t* naluStart = startCode + startCodeLen;
if (naluStart >= end) break;
int nextStartCodeLen = 0;
const uint8_t* nextStartCode = findStartCode(naluStart, end, nextStartCodeLen);
uint32_t naluSize = nextStartCode
? static_cast<uint32_t>(nextStartCode - naluStart)
: static_cast<uint32_t>(end - naluStart);
if (naluSize > 0) {
uint8_t naluType = naluStart[0] & 0x1F;
// Skip SPS (7), PPS (8), AUD (9)
if (naluType != 7 && naluType != 8 && naluType != 9) {
result.push_back(static_cast<uint8_t>(naluSize >> 24));
result.push_back(static_cast<uint8_t>(naluSize >> 16));
result.push_back(static_cast<uint8_t>(naluSize >> 8));
result.push_back(static_cast<uint8_t>(naluSize & 0xFF));
result.insert(result.end(), naluStart, naluStart + naluSize);
}
}
startCode = nextStartCode;
startCodeLen = nextStartCodeLen;
}
return result;
}
std::vector<uint8_t> RtmpSink::BuildAvccFromAnnexB(const uint8_t* sps, uint32_t spsSize,
const uint8_t* pps, uint32_t ppsSize) {
// AVCDecoderConfigurationRecord
std::vector<uint8_t> record;
record.reserve(11 + spsSize + ppsSize);
record.push_back(0x01); // configurationVersion
record.push_back(spsSize > 1 ? sps[1] : 0x42); // AVCProfileIndication
record.push_back(spsSize > 2 ? sps[2] : 0x00); // profile_compatibility
record.push_back(spsSize > 3 ? sps[3] : 0x1E); // AVCLevelIndication
record.push_back(0xFF); // lengthSizeMinusOne = 3 (4 bytes)
record.push_back(0xE1); // numOfSequenceParameterSets = 1
// SPS length (big-endian)
record.push_back(static_cast<uint8_t>(spsSize >> 8));
record.push_back(static_cast<uint8_t>(spsSize & 0xFF));
record.insert(record.end(), sps, sps + spsSize);
record.push_back(0x01); // numOfPictureParameterSets = 1
// PPS length (big-endian)
record.push_back(static_cast<uint8_t>(ppsSize >> 8));
record.push_back(static_cast<uint8_t>(ppsSize & 0xFF));
record.insert(record.end(), pps, pps + ppsSize);
return record;
}

View File

@@ -0,0 +1,43 @@
#pragma once
#include "rtmp_client.h"
#include <cstdint>
#include <string>
#include <vector>
/**
* RTMP sink that bridges encoded packets to an RTMP endpoint.
* Ported from FLCKRtmpSink (UE5 LCKStreaming plugin).
* Handles Annex-B to AVCC conversion, sequence headers, and FLV framing.
*/
class RtmpSink {
public:
RtmpSink();
~RtmpSink();
void SetRtmpUrl(const std::string& url);
bool Open(uint32_t width, uint32_t height, uint32_t framerate,
uint32_t sampleRate, uint32_t numChannels);
void OnVideoFormatReady(const uint8_t* extraData, uint32_t extraDataSize);
void OnAudioFormatReady(uint32_t sampleRate, uint32_t numChannels);
void SendVideoPacket(const uint8_t* data, uint32_t size,
int64_t timestampMs, bool isKeyframe);
void SendAudioPacket(const uint8_t* data, uint32_t size, int64_t timestampMs);
void Close();
bool IsOpen() const;
private:
bool TryExtractAndSendSequenceHeader(const uint8_t* data, uint32_t size);
static std::vector<uint8_t> ConvertAnnexBToAvcc(const uint8_t* data, uint32_t size);
static std::vector<uint8_t> BuildAvccFromAnnexB(const uint8_t* sps, uint32_t spsSize,
const uint8_t* pps, uint32_t ppsSize);
RtmpClient rtmpClient;
std::string rtmpUrl;
bool isOpen = false;
bool videoHeaderSent = false;
bool audioHeaderSent = false;
uint32_t storedSampleRate = 48000;
uint32_t storedNumChannels = 2;
};

View File

@@ -0,0 +1,998 @@
#include "streaming_engine.h"
#include <android/log.h>
#include <GLES3/gl3.h>
#include <GLES2/gl2ext.h>
#include <unistd.h>
#include <cstring>
#include <algorithm>
#define TAG "LckStreamingEngine"
#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__)
// 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;
out vec2 vTexCoord;
void main() {
gl_Position = vec4(aPos, 0.0, 1.0);
vTexCoord = aTexCoord;
}
)";
static const char* BLIT_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);
}
)";
// 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);
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;
}
StreamingEngine::StreamingEngine() {}
StreamingEngine::~StreamingEngine() {
Stop();
for (auto* sink : sinks) {
delete sink;
}
sinks.clear();
}
bool StreamingEngine::Configure(int w, int h, int vBitrate, int aBitrate,
int sr, int ch, int kfi) {
width = w;
height = h;
videoBitrate = vBitrate;
audioBitrate = aBitrate;
sampleRate = sr;
channels = ch;
keyframeInterval = kfi;
return true;
}
int StreamingEngine::AddDestination(const std::string& rtmpUrl) {
auto* sink = new RtmpSink();
sink->SetRtmpUrl(rtmpUrl);
sinks.push_back(sink);
return static_cast<int>(sinks.size() - 1);
}
bool StreamingEngine::InitVideoEncoder() {
videoEncoder = AMediaCodec_createEncoderByType("video/avc");
if (!videoEncoder) {
LOGE("Failed to create video encoder");
return false;
}
AMediaFormat* format = AMediaFormat_new();
AMediaFormat_setString(format, AMEDIAFORMAT_KEY_MIME, "video/avc");
AMediaFormat_setInt32(format, AMEDIAFORMAT_KEY_WIDTH, width);
AMediaFormat_setInt32(format, AMEDIAFORMAT_KEY_HEIGHT, height);
AMediaFormat_setInt32(format, AMEDIAFORMAT_KEY_BIT_RATE, videoBitrate);
AMediaFormat_setInt32(format, AMEDIAFORMAT_KEY_FRAME_RATE, framerate);
AMediaFormat_setInt32(format, AMEDIAFORMAT_KEY_I_FRAME_INTERVAL, keyframeInterval);
AMediaFormat_setInt32(format, AMEDIAFORMAT_KEY_COLOR_FORMAT, 0x7F000789); // COLOR_FormatSurface
AMediaFormat_setInt32(format, "profile", 8); // AVCProfileHigh
AMediaFormat_setInt32(format, "level", 2048); // AVCLevel42
AMediaFormat_setInt32(format, "bitrate-mode", 2); // CBR
media_status_t status = AMediaCodec_configure(videoEncoder, format, nullptr, nullptr,
AMEDIACODEC_CONFIGURE_FLAG_ENCODE);
AMediaFormat_delete(format);
if (status != AMEDIA_OK) {
LOGE("Video encoder configure failed: %d", status);
AMediaCodec_delete(videoEncoder);
videoEncoder = nullptr;
return false;
}
status = AMediaCodec_createInputSurface(videoEncoder, &encoderSurface);
if (status != AMEDIA_OK || !encoderSurface) {
LOGE("Failed to create encoder input surface: %d", status);
AMediaCodec_delete(videoEncoder);
videoEncoder = nullptr;
return false;
}
status = AMediaCodec_start(videoEncoder);
if (status != AMEDIA_OK) {
LOGE("Video encoder start failed: %d", status);
ANativeWindow_release(encoderSurface);
encoderSurface = nullptr;
AMediaCodec_delete(videoEncoder);
videoEncoder = nullptr;
return false;
}
LOGI("Video encoder started: %dx%d @ %d bps", width, height, videoBitrate);
return true;
}
bool StreamingEngine::InitAudioEncoder() {
audioEncoder = AMediaCodec_createEncoderByType("audio/mp4a-latm");
if (!audioEncoder) {
LOGE("Failed to create audio encoder");
return false;
}
AMediaFormat* format = AMediaFormat_new();
AMediaFormat_setString(format, AMEDIAFORMAT_KEY_MIME, "audio/mp4a-latm");
AMediaFormat_setInt32(format, AMEDIAFORMAT_KEY_AAC_PROFILE, 2); // AAC-LC
AMediaFormat_setInt32(format, AMEDIAFORMAT_KEY_BIT_RATE, audioBitrate);
AMediaFormat_setInt32(format, AMEDIAFORMAT_KEY_SAMPLE_RATE, sampleRate);
AMediaFormat_setInt32(format, AMEDIAFORMAT_KEY_CHANNEL_COUNT, channels);
media_status_t status = AMediaCodec_configure(audioEncoder, format, nullptr, nullptr,
AMEDIACODEC_CONFIGURE_FLAG_ENCODE);
AMediaFormat_delete(format);
if (status != AMEDIA_OK) {
LOGE("Audio encoder configure failed: %d", status);
AMediaCodec_delete(audioEncoder);
audioEncoder = nullptr;
return false;
}
status = AMediaCodec_start(audioEncoder);
if (status != AMEDIA_OK) {
LOGE("Audio encoder start failed: %d", status);
AMediaCodec_delete(audioEncoder);
audioEncoder = nullptr;
return false;
}
LOGI("Audio encoder started: %dHz %dch @ %d bps", sampleRate, channels, audioBitrate);
return true;
}
bool StreamingEngine::InitBlitResources() {
GLuint vs = CompileShader(GL_VERTEX_SHADER, BLIT_VERTEX_SHADER);
GLuint fs = CompileShader(GL_FRAGMENT_SHADER, BLIT_FRAGMENT_SHADER);
if (!vs || !fs) return false;
blitProgram = glCreateProgram();
glAttachShader(blitProgram, vs);
glAttachShader(blitProgram, fs);
glLinkProgram(blitProgram);
glDeleteShader(vs);
glDeleteShader(fs);
GLint linkStatus;
glGetProgramiv(blitProgram, GL_LINK_STATUS, &linkStatus);
if (!linkStatus) {
LOGE("Blit program link failed");
glDeleteProgram(blitProgram);
blitProgram = 0;
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,
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, &blitVao);
glGenBuffers(1, &blitVbo);
glBindVertexArray(blitVao);
glBindBuffer(GL_ARRAY_BUFFER, blitVbo);
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);
// 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() {
if (running.load()) return true;
if (width <= 0 || height <= 0) {
LOGE("Invalid dimensions: %dx%d", width, height);
return false;
}
if (sinks.empty() && !cortexRecordingEnabled) {
LOGE("No destinations and cortex not enabled");
return false;
}
running.store(true);
firstVideoFrame = true;
startTimestampNs = 0;
lastComposeTimeNs = 0;
lastGameVideoFrameNs = 0;
hasExternalAudioSource.store(false);
audioPcmBuffer.clear();
audioBufferSamplesWritten = 0;
statsVideoBytes = 0;
statsAudioBytes = 0;
statsFrameCount = 0;
statsLastUpdateNs = 0;
encoderThread = std::thread(&StreamingEngine::EncoderThreadFunc, this);
return true;
}
void StreamingEngine::EncoderThreadFunc() {
LOGI("Encoder thread started");
// Init EGL
if (!eglContext.Init()) {
LOGE("EGL init failed");
running.store(false);
if (errorCallback) errorCallback(1, "EGL initialization failed");
return;
}
// Init video encoder (creates input surface)
if (!InitVideoEncoder()) {
LOGE("Video encoder init failed");
eglContext.Release();
running.store(false);
if (errorCallback) errorCallback(2, "Video encoder initialization failed");
return;
}
// Create EGL window surface from encoder input surface
if (!eglContext.CreateWindowSurface(encoderSurface)) {
LOGE("EGL window surface creation failed");
AMediaCodec_stop(videoEncoder);
AMediaCodec_delete(videoEncoder);
videoEncoder = nullptr;
ANativeWindow_release(encoderSurface);
encoderSurface = nullptr;
eglContext.Release();
running.store(false);
if (errorCallback) errorCallback(3, "EGL window surface creation failed");
return;
}
if (!eglContext.MakeCurrent()) {
LOGE("EGL make current failed");
running.store(false);
if (errorCallback) errorCallback(4, "EGL make current failed");
return;
}
// Init blit resources
if (!InitBlitResources()) {
LOGE("Blit resources init failed");
running.store(false);
if (errorCallback) errorCallback(5, "Blit resources initialization failed");
return;
}
// Init audio encoder
if (!InitAudioEncoder()) {
LOGW("Audio encoder init failed, continuing without audio");
}
// Open RTMP sinks
for (auto* sink : sinks) {
if (!sink->Open(width, height, framerate, sampleRate, channels)) {
LOGE("Failed to open RTMP sink");
if (errorCallback) errorCallback(6, "RTMP connection failed");
}
}
LOGI("Streaming engine fully initialized");
// Main encoder loop
while (running.load()) {
// Process pending preview and layer ops (must run on GL thread)
ProcessPendingPreviewOps();
ProcessPendingLayerOps();
// Process video frames
bool hadVideoFrames = false;
{
std::lock_guard<std::mutex> lock(videoMutex);
hadVideoFrames = !videoQueue.empty();
if (hadVideoFrames && statsFrameCount % 30 == 0) {
LOGI("Processing %zu game video frames", videoQueue.size());
}
for (auto& frame : videoQueue) {
ProcessVideoFrame(frame);
// Release buffer back to the game's pool
if (bufferReleasedCallback) {
bufferReleasedCallback(frame.bufferIndex);
}
}
videoQueue.clear();
}
// Generate standby frames only when no game input has arrived for STANDBY_TIMEOUT_NS.
// This prevents standby frames from being interleaved with game frames.
if (!hadVideoFrames && compositionPipeline.IsInitialized()) {
auto now = std::chrono::steady_clock::now().time_since_epoch();
int64_t nowNs = std::chrono::duration_cast<std::chrono::nanoseconds>(now).count();
int64_t frameIntervalNs = 1000000000LL / framerate;
bool gameActive = lastGameVideoFrameNs > 0 &&
(nowNs - lastGameVideoFrameNs) < STANDBY_TIMEOUT_NS;
if (!gameActive && 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();
}
lastComposeTimeNs = nowNs;
}
}
// Process audio frames from external sources (accumulate into PCM buffer)
{
std::lock_guard<std::mutex> lock(audioMutex);
for (auto& frame : audioQueue) {
ProcessAudioFrame(frame);
}
audioQueue.clear();
}
// Drain encoders — always drain both regardless of pending input
DrainVideoEncoder();
if (audioEncoder) {
DrainAudioEncoder();
}
// Flush accumulated audio in AAC-aligned chunks
if (audioEncoder) {
FlushAudioBuffer();
}
// Update stats every second regardless of frame output
UpdateStats();
// Don't spin-wait
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
// Cleanup
LOGI("Encoder thread shutting down");
// Stop cortex recording if active
if (cortexRecordingEnabled) {
cortexRecorder.StopSession();
cortexRecordingEnabled = false;
}
ReleaseBlitResources();
eglContext.DestroyPreviewSurface();
hasPreview = false;
for (auto* sink : sinks) {
sink->Close();
}
if (videoEncoder) {
AMediaCodec_stop(videoEncoder);
AMediaCodec_delete(videoEncoder);
videoEncoder = nullptr;
}
if (encoderSurface) {
ANativeWindow_release(encoderSurface);
encoderSurface = nullptr;
}
if (audioEncoder) {
AMediaCodec_stop(audioEncoder);
AMediaCodec_delete(audioEncoder);
audioEncoder = nullptr;
}
eglContext.Release();
LOGI("Encoder thread stopped");
}
void StreamingEngine::ProcessVideoFrame(const VideoFrame& frame) {
if (!frame.buffer) return;
// Use wall-clock relative timestamps (matching standby frames) to ensure
// monotonically increasing PTS. The game's own timestamp is relative to
// AppStreamingTime which starts at 0, but standby frames may have already
// advanced the encoder's PTS — backward timestamps cause MediaCodec to drop frames.
auto now = std::chrono::steady_clock::now().time_since_epoch();
int64_t nowNs = std::chrono::duration_cast<std::chrono::nanoseconds>(now).count();
if (firstVideoFrame) {
startTimestampNs = nowNs;
firstVideoFrame = false;
}
int64_t presentationNs = nowNs - startTimestampNs;
lastGameVideoFrameNs = nowNs;
// Wait on GPU fence
eglContext.WaitFence(frame.fenceFd);
// Import HardwareBuffer as OES texture
GLuint texture = eglContext.ImportHardwareBuffer(frame.buffer);
if (texture == 0) {
LOGW("Failed to import HardwareBuffer as texture");
return;
}
static int sProcessCount = 0;
if (++sProcessCount <= 3 || sProcessCount % 300 == 0) {
LOGI("ProcessVideoFrame: #%d tex=%u pts=%lldms buf=%p idx=%d",
sProcessCount, texture, (long long)(presentationNs / 1000000),
frame.buffer, frame.bufferIndex);
}
// Compose: game frame + overlay layers → FBO
compositionPipeline.Compose(texture);
GLuint composedTex = compositionPipeline.GetComposedTexture();
// Blit composed texture → encoder surface
eglContext.MakeEncoderCurrent();
BlitComposedToSurface(composedTex, width, height);
eglContext.SetPresentationTime(presentationNs);
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
lastComposeTimeNs = nowNs;
}
void StreamingEngine::BlitComposedToSurface(GLuint composedTex, int viewportW, int viewportH) {
glViewport(0, 0, viewportW, viewportH);
glUseProgram(blitFboProgram);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, composedTex);
glUniform1i(glGetUniformLocation(blitFboProgram, "uTexture"), 0);
glBindVertexArray(blitVao);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
}
void StreamingEngine::ProcessAudioFrame(const AudioFrame& frame) {
if (!audioEncoder || frame.pcmData.empty()) return;
// Accumulate PCM data; FlushAudioBuffer will submit in AAC-aligned chunks
audioPcmBuffer.insert(audioPcmBuffer.end(), frame.pcmData.begin(), frame.pcmData.end());
}
void StreamingEngine::FlushAudioBuffer() {
if (!audioEncoder || audioPcmBuffer.empty()) return;
// Bytes per AAC frame: 1024 samples * channels * 2 bytes (16-bit PCM)
const size_t aacFrameBytes = AAC_FRAME_SAMPLES * channels * 2;
while (audioPcmBuffer.size() >= aacFrameBytes) {
ssize_t inputIndex = AMediaCodec_dequeueInputBuffer(audioEncoder, 5000);
if (inputIndex < 0) {
// No buffer available — leave data for next loop iteration
break;
}
size_t bufferSize;
uint8_t* inputBuffer = AMediaCodec_getInputBuffer(audioEncoder, inputIndex, &bufferSize);
if (!inputBuffer) break;
size_t copySize = std::min(aacFrameBytes, bufferSize);
memcpy(inputBuffer, audioPcmBuffer.data(), copySize);
// Timestamp based on total samples submitted (continuous, no jitter)
int64_t timestampUs = audioBufferSamplesWritten * 1000000LL / sampleRate;
AMediaCodec_queueInputBuffer(audioEncoder, inputIndex, 0, copySize,
timestampUs, 0);
audioBufferSamplesWritten += AAC_FRAME_SAMPLES;
// Remove consumed data from front
audioPcmBuffer.erase(audioPcmBuffer.begin(), audioPcmBuffer.begin() + copySize);
}
// Prevent unbounded accumulation if encoder is stuck
const size_t maxBufferBytes = aacFrameBytes * 16; // ~340ms of audio
if (audioPcmBuffer.size() > maxBufferBytes) {
size_t excess = audioPcmBuffer.size() - maxBufferBytes;
audioPcmBuffer.erase(audioPcmBuffer.begin(), audioPcmBuffer.begin() + excess);
LOGW("Audio buffer overflow, dropped %zu bytes", excess);
}
}
void StreamingEngine::DrainVideoEncoder() {
if (!videoEncoder) return;
AMediaCodecBufferInfo info;
ssize_t outputIndex;
while ((outputIndex = AMediaCodec_dequeueOutputBuffer(videoEncoder, &info, 0)) >= 0) {
if (info.size > 0) {
size_t outSize;
uint8_t* outputData = AMediaCodec_getOutputBuffer(videoEncoder, outputIndex, &outSize);
if (outputData) {
bool isKeyframe = (info.flags & AMEDIACODEC_BUFFER_FLAG_KEY_FRAME) != 0;
int64_t timestampMs = info.presentationTimeUs / 1000;
for (auto* sink : sinks) {
sink->SendVideoPacket(outputData + info.offset, info.size,
timestampMs, isKeyframe);
}
// Feed clip recorder (skip codec config frames)
if (clipRecordingEnabled && !(info.flags & AMEDIACODEC_BUFFER_FLAG_CODEC_CONFIG)) {
clipRecorder.FeedVideoPacket(outputData + info.offset, info.size,
info.presentationTimeUs, isKeyframe);
}
// Feed cortex recorder (skip codec config frames)
if (cortexRecordingEnabled && !(info.flags & AMEDIACODEC_BUFFER_FLAG_CODEC_CONFIG)) {
cortexRecorder.FeedVideoPacket(outputData + info.offset, info.size,
info.presentationTimeUs, isKeyframe);
}
// Feed encoded frame callback for WebRTC passthrough
if (encodedFrameCallback && !(info.flags & AMEDIACODEC_BUFFER_FLAG_CODEC_CONFIG)) {
encodedFrameCallback(outputData + info.offset, info.size,
isKeyframe, info.presentationTimeUs);
}
std::lock_guard<std::mutex> lock(statsMutex);
statsVideoBytes += info.size;
statsFrameCount++;
}
}
if (info.flags & AMEDIACODEC_BUFFER_FLAG_CODEC_CONFIG) {
// Sequence header (SPS/PPS) — forward to sinks
size_t outSize;
uint8_t* configData = AMediaCodec_getOutputBuffer(videoEncoder, outputIndex, &outSize);
if (configData) {
for (auto* sink : sinks) {
sink->OnVideoFormatReady(configData + info.offset, info.size);
}
// Forward SPS/PPS to clip recorder
if (clipRecordingEnabled) {
clipRecorder.SetVideoFormat(configData + info.offset, info.size);
}
// Forward SPS/PPS to cortex recorder
if (cortexRecordingEnabled) {
cortexRecorder.SetVideoFormat(configData + info.offset, info.size);
}
}
}
AMediaCodec_releaseOutputBuffer(videoEncoder, outputIndex, false);
}
if (outputIndex == AMEDIACODEC_INFO_OUTPUT_FORMAT_CHANGED) {
AMediaFormat* newFormat = AMediaCodec_getOutputFormat(videoEncoder);
if (newFormat) {
LOGI("Video encoder output format changed");
AMediaFormat_delete(newFormat);
}
}
}
void StreamingEngine::DrainAudioEncoder() {
if (!audioEncoder) return;
AMediaCodecBufferInfo info;
ssize_t outputIndex;
while ((outputIndex = AMediaCodec_dequeueOutputBuffer(audioEncoder, &info, 0)) >= 0) {
if (info.size > 0 && !(info.flags & AMEDIACODEC_BUFFER_FLAG_CODEC_CONFIG)) {
size_t outSize;
uint8_t* outputData = AMediaCodec_getOutputBuffer(audioEncoder, outputIndex, &outSize);
if (outputData) {
int64_t timestampMs = info.presentationTimeUs / 1000;
for (auto* sink : sinks) {
sink->SendAudioPacket(outputData + info.offset, info.size, timestampMs);
}
// Feed clip recorder
if (clipRecordingEnabled) {
clipRecorder.FeedAudioPacket(outputData + info.offset, info.size,
info.presentationTimeUs);
}
// Feed cortex recorder
if (cortexRecordingEnabled) {
cortexRecorder.FeedAudioPacket(outputData + info.offset, info.size,
info.presentationTimeUs);
}
std::lock_guard<std::mutex> lock(statsMutex);
statsAudioBytes += info.size;
}
}
if (info.flags & AMEDIACODEC_BUFFER_FLAG_CODEC_CONFIG) {
// AAC config — sinks handle audio format via Open()
// Forward to clip recorder
if (clipRecordingEnabled) {
size_t outSize;
uint8_t* configData = AMediaCodec_getOutputBuffer(audioEncoder, outputIndex, &outSize);
if (configData) {
clipRecorder.SetAudioFormat(configData + info.offset, info.size);
}
}
// Forward to cortex recorder
if (cortexRecordingEnabled) {
size_t outSize;
uint8_t* configData = AMediaCodec_getOutputBuffer(audioEncoder, outputIndex, &outSize);
if (configData) {
cortexRecorder.SetAudioFormat(configData + info.offset, info.size);
}
}
}
AMediaCodec_releaseOutputBuffer(audioEncoder, outputIndex, false);
}
}
void StreamingEngine::UpdateStats() {
auto now = std::chrono::steady_clock::now().time_since_epoch();
int64_t nowNs = std::chrono::duration_cast<std::chrono::nanoseconds>(now).count();
std::lock_guard<std::mutex> lock(statsMutex);
if (statsLastUpdateNs == 0) {
statsLastUpdateNs = nowNs;
return;
}
int64_t elapsedNs = nowNs - statsLastUpdateNs;
if (elapsedNs >= 1000000000LL) { // Every second
double elapsedSec = elapsedNs / 1000000000.0;
currentStats.videoBitrate = static_cast<int64_t>(statsVideoBytes * 8 / elapsedSec);
currentStats.audioBitrate = static_cast<int64_t>(statsAudioBytes * 8 / elapsedSec);
currentStats.fps = static_cast<int>(statsFrameCount / elapsedSec);
statsVideoBytes = 0;
statsAudioBytes = 0;
statsFrameCount = 0;
statsLastUpdateNs = nowNs;
if (statsCallback) {
statsCallback(currentStats);
}
}
}
static int sVideoSubmitCount = 0;
void StreamingEngine::SubmitVideoFrame(AHardwareBuffer* buffer, int64_t timestampNs, int fenceFd, int bufferIndex) {
if (!running.load()) {
if (fenceFd >= 0) close(fenceFd);
// Release buffer immediately if not running
if (bufferReleasedCallback) bufferReleasedCallback(bufferIndex);
return;
}
if (++sVideoSubmitCount % 30 == 1) {
LOGI("SubmitVideoFrame: frame #%d idx=%d buffer=%p ts=%lld fence=%d",
sVideoSubmitCount, bufferIndex, buffer, (long long)timestampNs, fenceFd);
}
VideoFrame frame;
frame.buffer = buffer;
frame.timestampNs = timestampNs;
frame.fenceFd = fenceFd;
frame.bufferIndex = bufferIndex;
std::lock_guard<std::mutex> lock(videoMutex);
videoQueue.push_back(frame);
}
void StreamingEngine::SubmitAudioFrame(const uint8_t* pcmData, size_t pcmSize, int64_t timestampNs) {
if (!running.load()) return;
hasExternalAudioSource.store(true);
AudioFrame frame;
frame.pcmData.assign(pcmData, pcmData + pcmSize);
frame.timestampNs = timestampNs;
std::lock_guard<std::mutex> lock(audioMutex);
audioQueue.push_back(std::move(frame));
}
void StreamingEngine::Stop() {
if (!running.load()) return;
LOGI("Stopping streaming engine");
running.store(false);
if (encoderThread.joinable()) {
encoderThread.join();
}
LOGI("Streaming engine stopped");
}
void StreamingEngine::SetStatsCallback(StatsCallback callback) {
statsCallback = std::move(callback);
}
void StreamingEngine::SetErrorCallback(ErrorCallback callback) {
errorCallback = std::move(callback);
}
void StreamingEngine::SetBufferReleasedCallback(BufferReleasedCallback callback) {
bufferReleasedCallback = std::move(callback);
}
// --- Preview surface ---
void StreamingEngine::SetPreviewSurface(ANativeWindow* window) {
if (!window) return;
ANativeWindow_acquire(window);
std::lock_guard<std::mutex> lock(previewMutex);
pendingPreviewOps.push_back(PreviewSetOp{window});
}
void StreamingEngine::RemovePreviewSurface() {
std::lock_guard<std::mutex> lock(previewMutex);
pendingPreviewOps.push_back(PreviewRemoveOp{});
}
void StreamingEngine::ProcessPendingPreviewOps() {
std::vector<PreviewOp> ops;
{
std::lock_guard<std::mutex> lock(previewMutex);
ops.swap(pendingPreviewOps);
}
for (auto& op : ops) {
if (auto* setOp = std::get_if<PreviewSetOp>(&op)) {
eglContext.DestroyPreviewSurface();
if (eglContext.CreatePreviewSurface(setOp->window)) {
hasPreview = true;
LOGI("Preview surface set");
} else {
ANativeWindow_release(setOp->window);
hasPreview = false;
}
// MakeEncoderCurrent since CreatePreviewSurface may change current
eglContext.MakeEncoderCurrent();
} else if (std::get_if<PreviewRemoveOp>(&op)) {
eglContext.DestroyPreviewSurface();
hasPreview = false;
eglContext.MakeEncoderCurrent();
LOGI("Preview surface removed");
}
}
}
// --- Composition layer management ---
int StreamingEngine::AddCompositionLayer(const uint8_t* rgbaData, int w, int h,
float posX, float posY,
float scaleX, float scaleY,
float rotation, float opacity, int zOrder,
const std::string& tag) {
int id = nextLayerId.fetch_add(1);
LayerAddOp addOp;
addOp.rgbaData.assign(rgbaData, rgbaData + (w * h * 4));
addOp.w = w;
addOp.h = h;
addOp.posX = posX;
addOp.posY = posY;
addOp.scaleX = scaleX;
addOp.scaleY = scaleY;
addOp.rotation = rotation;
addOp.opacity = opacity;
addOp.zOrder = zOrder;
addOp.tag = tag;
addOp.assignedId = id;
std::lock_guard<std::mutex> lock(layerOpMutex);
pendingLayerOps.push_back(std::move(addOp));
return id;
}
void StreamingEngine::RemoveCompositionLayer(int layerId) {
std::lock_guard<std::mutex> lock(layerOpMutex);
pendingLayerOps.push_back(LayerRemoveOp{layerId});
}
void StreamingEngine::UpdateCompositionLayerTransform(int layerId, float posX, float posY,
float scaleX, float scaleY,
float rotation) {
std::lock_guard<std::mutex> lock(layerOpMutex);
pendingLayerOps.push_back(LayerTransformOp{layerId, posX, posY, scaleX, scaleY, rotation});
}
void StreamingEngine::UpdateCompositionLayerOpacity(int layerId, float opacity) {
std::lock_guard<std::mutex> lock(layerOpMutex);
pendingLayerOps.push_back(LayerOpacityOp{layerId, opacity});
}
void StreamingEngine::SetCompositionLayerEnabled(int layerId, bool enabled) {
std::lock_guard<std::mutex> lock(layerOpMutex);
pendingLayerOps.push_back(LayerEnabledOp{layerId, enabled});
}
void StreamingEngine::ProcessPendingLayerOps() {
std::vector<LayerOp> ops;
{
std::lock_guard<std::mutex> lock(layerOpMutex);
ops.swap(pendingLayerOps);
}
for (auto& op : ops) {
if (auto* addOp = std::get_if<LayerAddOp>(&op)) {
GLuint tex = CompositionPipeline::UploadTexture(
addOp->rgbaData.data(), addOp->w, addOp->h);
if (tex) {
CompositionTransform transform;
transform.posX = addOp->posX;
transform.posY = addOp->posY;
transform.scaleX = addOp->scaleX;
transform.scaleY = addOp->scaleY;
transform.rotation = addOp->rotation;
compositionPipeline.AddLayer(tex, addOp->w, addOp->h, transform,
addOp->opacity, addOp->zOrder, addOp->tag);
}
} else if (auto* removeOp = std::get_if<LayerRemoveOp>(&op)) {
compositionPipeline.RemoveLayer(removeOp->layerId);
} else if (auto* transformOp = std::get_if<LayerTransformOp>(&op)) {
CompositionTransform t;
t.posX = transformOp->posX;
t.posY = transformOp->posY;
t.scaleX = transformOp->scaleX;
t.scaleY = transformOp->scaleY;
t.rotation = transformOp->rotation;
compositionPipeline.UpdateLayerTransform(transformOp->layerId, t);
} else if (auto* opacityOp = std::get_if<LayerOpacityOp>(&op)) {
compositionPipeline.UpdateLayerOpacity(opacityOp->layerId, opacityOp->opacity);
} else if (auto* enabledOp = std::get_if<LayerEnabledOp>(&op)) {
compositionPipeline.SetLayerEnabled(enabledOp->layerId, enabledOp->enabled);
}
}
}
// --- Clip recording ---
void StreamingEngine::EnableClipRecording(int w, int h) {
clipRecorder.Configure(w, h, sampleRate, channels, audioBitrate);
clipRecorder.Start();
clipRecordingEnabled = true;
LOGI("Clip recording enabled: %dx%d", w, h);
}
bool StreamingEngine::FlushClip(const std::string& outputDir) {
if (!clipRecordingEnabled) return false;
return clipRecorder.FlushClip(outputDir);
}
void StreamingEngine::DisableClipRecording() {
clipRecordingEnabled = false;
clipRecorder.Stop();
LOGI("Clip recording disabled");
}
void StreamingEngine::SetClipReadyCallback(ClipRecorder::ClipReadyCallback callback) {
clipRecorder.SetClipReadyCallback(std::move(callback));
}
// --- Cortex recording ---
void StreamingEngine::EnableCortexRecording(const std::string& sessionDir, int maxMinutes) {
cortexRecorder.Configure(width, height, sampleRate, channels, audioBitrate);
cortexRecorder.SetMaxDurationMinutes(maxMinutes);
cortexRecorder.StartSession(sessionDir);
cortexRecordingEnabled = true;
LOGI("Cortex recording enabled: %s (%d min)", sessionDir.c_str(), maxMinutes);
}
void StreamingEngine::DisableCortexRecording() {
cortexRecordingEnabled = false;
cortexRecorder.StopSession();
LOGI("Cortex recording disabled");
}
void StreamingEngine::SetCortexSegmentCallback(CortexRecorder::SegmentCallback cb) {
cortexRecorder.SetSegmentCallback(std::move(cb));
}
void StreamingEngine::SetEncodedFrameCallback(EncodedFrameCallback callback) {
encodedFrameCallback = std::move(callback);
}

View File

@@ -0,0 +1,248 @@
#pragma once
#include "egl_context.h"
#include "composition_pipeline.h"
#include "rtmp_sink.h"
#include "clip_recorder.h"
#include "cortex_recorder.h"
#include <media/NdkMediaCodec.h>
#include <media/NdkMediaFormat.h>
#include <android/hardware_buffer.h>
#include <android/native_window.h>
#include <atomic>
#include <cstdint>
#include <functional>
#include <mutex>
#include <string>
#include <thread>
#include <variant>
#include <vector>
struct VideoFrame {
AHardwareBuffer* buffer;
int64_t timestampNs;
int fenceFd; // -1 if no fence
int bufferIndex; // pool slot index for release callback
};
struct AudioFrame {
std::vector<uint8_t> pcmData;
int64_t timestampNs;
};
struct StreamingStats {
int64_t videoBitrate = 0;
int64_t audioBitrate = 0;
int fps = 0;
int droppedFrames = 0;
};
/**
* Streaming engine: imports HardwareBuffers via EGL, encodes with AMediaCodec,
* and streams via RTMP to one or more destinations.
*
* All encoding happens in native code (zero-copy pipeline).
*/
class StreamingEngine {
public:
using StatsCallback = std::function<void(const StreamingStats&)>;
using ErrorCallback = std::function<void(int code, const std::string& message)>;
using BufferReleasedCallback = std::function<void(int bufferIndex)>;
using EncodedFrameCallback = std::function<void(const uint8_t* data, size_t size, bool isKeyFrame, int64_t timestampUs)>;
StreamingEngine();
~StreamingEngine();
/** Configure the engine. Must be called before Start(). */
bool Configure(int width, int height, int videoBitrate, int audioBitrate,
int sampleRate, int channels, int keyframeInterval);
/** Add an RTMP destination. Returns destination index. */
int AddDestination(const std::string& rtmpUrl);
/** Start encoding and streaming. */
bool Start();
/** Submit a video frame from HardwareBuffer. Non-blocking. */
void SubmitVideoFrame(AHardwareBuffer* buffer, int64_t timestampNs, int fenceFd, int bufferIndex);
/** Submit audio PCM data. Non-blocking. */
void SubmitAudioFrame(const uint8_t* pcmData, size_t pcmSize, int64_t timestampNs);
/** Stop encoding and streaming. Blocks until clean shutdown. */
void Stop();
/** Set callbacks. */
void SetStatsCallback(StatsCallback callback);
void SetErrorCallback(ErrorCallback callback);
void SetBufferReleasedCallback(BufferReleasedCallback callback);
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);
// Clip recording
void EnableClipRecording(int width, int height);
bool FlushClip(const std::string& outputDir);
void DisableClipRecording();
void SetClipReadyCallback(ClipRecorder::ClipReadyCallback callback);
// Cortex recording
void EnableCortexRecording(const std::string& sessionDir, int maxMinutes);
void DisableCortexRecording();
void SetCortexSegmentCallback(CortexRecorder::SegmentCallback cb);
/** Set callback to receive encoded H.264 NAL units (for WebRTC passthrough). */
void SetEncodedFrameCallback(EncodedFrameCallback callback);
private:
// Encoder thread
void EncoderThreadFunc();
void ProcessVideoFrame(const VideoFrame& frame);
void ProcessAudioFrame(const AudioFrame& frame);
void DrainVideoEncoder();
void DrainAudioEncoder();
void UpdateStats();
// 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;
int height = 0;
int videoBitrate = 6000000;
int audioBitrate = 128000;
int sampleRate = 48000;
int channels = 2;
int keyframeInterval = 2;
int framerate = 30;
// EGL
EglContext eglContext;
// 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;
// Audio encoder
AMediaCodec* audioEncoder = nullptr;
// Audio accumulation buffer — collects PCM and submits in 1024-sample AAC frames
std::vector<uint8_t> audioPcmBuffer;
int64_t audioBufferSamplesWritten = 0; // total samples submitted to encoder
static constexpr int AAC_FRAME_SAMPLES = 1024;
void FlushAudioBuffer();
// RTMP sinks (one per destination)
std::vector<RtmpSink*> sinks;
// Threading
std::thread encoderThread;
std::atomic<bool> running{false};
// Frame queues (protected by mutex)
std::mutex videoMutex;
std::vector<VideoFrame> videoQueue;
std::mutex audioMutex;
std::vector<AudioFrame> audioQueue;
// Preview surface — pending ops from non-GL threads
struct PreviewSetOp { ANativeWindow* window; };
struct PreviewRemoveOp {};
using PreviewOp = std::variant<PreviewSetOp, PreviewRemoveOp>;
std::mutex previewMutex;
std::vector<PreviewOp> pendingPreviewOps;
bool hasPreview = false;
// Layer ops — pending ops from non-GL threads
struct LayerAddOp {
std::vector<uint8_t> rgbaData;
int w, h;
float posX, posY, scaleX, scaleY, rotation, opacity;
int zOrder;
std::string tag;
int assignedId;
};
struct LayerRemoveOp { int layerId; };
struct LayerTransformOp { int layerId; float posX, posY, scaleX, scaleY, rotation; };
struct LayerOpacityOp { int layerId; float opacity; };
struct LayerEnabledOp { int layerId; bool enabled; };
using LayerOp = std::variant<LayerAddOp, LayerRemoveOp, LayerTransformOp,
LayerOpacityOp, LayerEnabledOp>;
std::mutex layerOpMutex;
std::vector<LayerOp> pendingLayerOps;
std::atomic<int> nextLayerId{1};
// Stats
std::mutex statsMutex;
StreamingStats currentStats;
int64_t statsVideoBytes = 0;
int64_t statsAudioBytes = 0;
int statsFrameCount = 0;
int64_t statsLastUpdateNs = 0;
// Start timestamp for relative timing
int64_t startTimestampNs = 0;
bool firstVideoFrame = true;
// Standby frame timing
int64_t lastComposeTimeNs = 0;
// Tracks when the last game video frame was received.
// Standby frames are only generated after a timeout with no game input.
int64_t lastGameVideoFrameNs = 0;
static constexpr int64_t STANDBY_TIMEOUT_NS = 500000000LL; // 500ms
// Tracks whether external audio has arrived (set by SubmitAudioFrame)
std::atomic<bool> hasExternalAudioSource{false};
// Callbacks
StatsCallback statsCallback;
ErrorCallback errorCallback;
BufferReleasedCallback bufferReleasedCallback;
EncodedFrameCallback encodedFrameCallback;
bool InitVideoEncoder();
bool InitAudioEncoder();
bool InitBlitResources();
void ReleaseBlitResources();
// Clip recording
ClipRecorder clipRecorder;
bool clipRecordingEnabled = false;
// Cortex recording
CortexRecorder cortexRecorder;
bool cortexRecordingEnabled = false;
};

View File

@@ -0,0 +1,164 @@
#ifndef __AMF_H__
#define __AMF_H__
/*
* Copyright (C) 2005-2008 Team XBMC
* http://www.xbmc.org
* Copyright (C) 2008-2009 Andrej Stepanchuk
* Copyright (C) 2009-2010 Howard Chu
*
* This file is part of librtmp.
*
* librtmp is free software; you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation; either version 2.1,
* or (at your option) any later version.
*
* librtmp is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with librtmp see the file COPYING. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
* Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/lgpl.html
*/
#include <stdint.h>
#ifndef TRUE
#define TRUE 1
#define FALSE 0
#endif
#ifdef __cplusplus
extern "C"
{
#endif
typedef enum
{ AMF_NUMBER = 0, AMF_BOOLEAN, AMF_STRING, AMF_OBJECT,
AMF_MOVIECLIP, /* reserved, not used */
AMF_NULL, AMF_UNDEFINED, AMF_REFERENCE, AMF_ECMA_ARRAY, AMF_OBJECT_END,
AMF_STRICT_ARRAY, AMF_DATE, AMF_LONG_STRING, AMF_UNSUPPORTED,
AMF_RECORDSET, /* reserved, not used */
AMF_XML_DOC, AMF_TYPED_OBJECT,
AMF_AVMPLUS, /* switch to AMF3 */
AMF_INVALID = 0xff
} AMFDataType;
typedef enum
{ AMF3_UNDEFINED = 0, AMF3_NULL, AMF3_FALSE, AMF3_TRUE,
AMF3_INTEGER, AMF3_DOUBLE, AMF3_STRING, AMF3_XML_DOC, AMF3_DATE,
AMF3_ARRAY, AMF3_OBJECT, AMF3_XML, AMF3_BYTE_ARRAY
} AMF3DataType;
typedef struct AVal
{
char *av_val;
int av_len;
} AVal;
#define AVC(str) {str,sizeof(str)-1}
#define AVMATCH(a1,a2) ((a1)->av_len == (a2)->av_len && !memcmp((a1)->av_val,(a2)->av_val,(a1)->av_len))
struct AMFObjectProperty;
typedef struct AMFObject
{
int o_num;
struct AMFObjectProperty *o_props;
} AMFObject;
typedef struct AMFObjectProperty
{
AVal p_name;
AMFDataType p_type;
union
{
double p_number;
AVal p_aval;
AMFObject p_object;
} p_vu;
int16_t p_UTCoffset;
} AMFObjectProperty;
char *AMF_EncodeString(char *output, char *outend, const AVal * str);
char *AMF_EncodeNumber(char *output, char *outend, double dVal);
char *AMF_EncodeInt16(char *output, char *outend, short nVal);
char *AMF_EncodeInt24(char *output, char *outend, int nVal);
char *AMF_EncodeInt32(char *output, char *outend, int nVal);
char *AMF_EncodeBoolean(char *output, char *outend, int bVal);
/* Shortcuts for AMFProp_Encode */
char *AMF_EncodeNamedString(char *output, char *outend, const AVal * name, const AVal * value);
char *AMF_EncodeNamedNumber(char *output, char *outend, const AVal * name, double dVal);
char *AMF_EncodeNamedBoolean(char *output, char *outend, const AVal * name, int bVal);
unsigned short AMF_DecodeInt16(const char *data);
unsigned int AMF_DecodeInt24(const char *data);
unsigned int AMF_DecodeInt32(const char *data);
void AMF_DecodeString(const char *data, AVal * str);
void AMF_DecodeLongString(const char *data, AVal * str);
int AMF_DecodeBoolean(const char *data);
double AMF_DecodeNumber(const char *data);
char *AMF_Encode(AMFObject * obj, char *pBuffer, char *pBufEnd);
char *AMF_EncodeEcmaArray(AMFObject *obj, char *pBuffer, char *pBufEnd);
char *AMF_EncodeArray(AMFObject *obj, char *pBuffer, char *pBufEnd);
int AMF_Decode(AMFObject * obj, const char *pBuffer, int nSize,
int bDecodeName);
int AMF_DecodeArray(AMFObject * obj, const char *pBuffer, int nSize,
int nArrayLen, int bDecodeName);
int AMF3_Decode(AMFObject * obj, const char *pBuffer, int nSize,
int bDecodeName);
void AMF_Dump(AMFObject * obj);
void AMF_Reset(AMFObject * obj);
void AMF_AddProp(AMFObject * obj, const AMFObjectProperty * prop);
int AMF_CountProp(AMFObject * obj);
AMFObjectProperty *AMF_GetProp(AMFObject * obj, const AVal * name,
int nIndex);
AMFDataType AMFProp_GetType(AMFObjectProperty * prop);
void AMFProp_SetNumber(AMFObjectProperty * prop, double dval);
void AMFProp_SetBoolean(AMFObjectProperty * prop, int bflag);
void AMFProp_SetString(AMFObjectProperty * prop, AVal * str);
void AMFProp_SetObject(AMFObjectProperty * prop, AMFObject * obj);
void AMFProp_GetName(AMFObjectProperty * prop, AVal * name);
void AMFProp_SetName(AMFObjectProperty * prop, AVal * name);
double AMFProp_GetNumber(AMFObjectProperty * prop);
int AMFProp_GetBoolean(AMFObjectProperty * prop);
void AMFProp_GetString(AMFObjectProperty * prop, AVal * str);
void AMFProp_GetObject(AMFObjectProperty * prop, AMFObject * obj);
int AMFProp_IsValid(AMFObjectProperty * prop);
char *AMFProp_Encode(AMFObjectProperty * prop, char *pBuffer, char *pBufEnd);
int AMF3Prop_Decode(AMFObjectProperty * prop, const char *pBuffer,
int nSize, int bDecodeName);
int AMFProp_Decode(AMFObjectProperty * prop, const char *pBuffer,
int nSize, int bDecodeName);
void AMFProp_Dump(AMFObjectProperty * prop);
void AMFProp_Reset(AMFObjectProperty * prop);
typedef struct AMF3ClassDef
{
AVal cd_name;
char cd_externalizable;
char cd_dynamic;
int cd_num;
AVal *cd_props;
} AMF3ClassDef;
void AMF3CD_AddProp(AMF3ClassDef * cd, AVal * prop);
AVal *AMF3CD_GetProp(AMF3ClassDef * cd, int idx);
#ifdef __cplusplus
}
#endif
#endif /* __AMF_H__ */

View File

@@ -0,0 +1,91 @@
/*
* Copyright (C) 2005-2008 Team XBMC
* http://www.xbmc.org
* Copyright (C) 2008-2009 Andrej Stepanchuk
* Copyright (C) 2009-2010 Howard Chu
*
* This file is part of librtmp.
*
* librtmp is free software; you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation; either version 2.1,
* or (at your option) any later version.
*
* librtmp is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with librtmp see the file COPYING. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
* Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/lgpl.html
*/
#ifndef __BYTES_H__
#define __BYTES_H__
#include <stdint.h>
#ifdef _WIN32
/* Windows is little endian only */
#define __LITTLE_ENDIAN 1234
#define __BIG_ENDIAN 4321
#define __BYTE_ORDER __LITTLE_ENDIAN
#define __FLOAT_WORD_ORDER __BYTE_ORDER
typedef unsigned char uint8_t;
#else /* !_WIN32 */
#include <sys/param.h>
#if defined(BYTE_ORDER) && !defined(__BYTE_ORDER)
#define __BYTE_ORDER BYTE_ORDER
#endif
#if defined(BIG_ENDIAN) && !defined(__BIG_ENDIAN)
#define __BIG_ENDIAN BIG_ENDIAN
#endif
#if defined(LITTLE_ENDIAN) && !defined(__LITTLE_ENDIAN)
#define __LITTLE_ENDIAN LITTLE_ENDIAN
#endif
#endif /* !_WIN32 */
/* define default endianness */
#ifndef __LITTLE_ENDIAN
#define __LITTLE_ENDIAN 1234
#endif
#ifndef __BIG_ENDIAN
#define __BIG_ENDIAN 4321
#endif
#ifndef __BYTE_ORDER
#warning "Byte order not defined on your system, assuming little endian!"
#define __BYTE_ORDER __LITTLE_ENDIAN
#endif
/* ok, we assume to have the same float word order and byte order if float word order is not defined */
#ifndef __FLOAT_WORD_ORDER
#warning "Float word order not defined, assuming the same as byte order!"
#define __FLOAT_WORD_ORDER __BYTE_ORDER
#endif
#if !defined(__BYTE_ORDER) || !defined(__FLOAT_WORD_ORDER)
#error "Undefined byte or float word order!"
#endif
#if __FLOAT_WORD_ORDER != __BIG_ENDIAN && __FLOAT_WORD_ORDER != __LITTLE_ENDIAN
#error "Unknown/unsupported float word order!"
#endif
#if __BYTE_ORDER != __BIG_ENDIAN && __BYTE_ORDER != __LITTLE_ENDIAN
#error "Unknown/unsupported byte order!"
#endif
#endif

View File

@@ -0,0 +1,402 @@
/* RTMPDump - Diffie-Hellmann Key Exchange
* Copyright (C) 2009 Andrej Stepanchuk
* Copyright (C) 2009-2010 Howard Chu
*
* This file is part of librtmp.
*
* librtmp is free software; you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation; either version 2.1,
* or (at your option) any later version.
*
* librtmp is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with librtmp see the file COPYING. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
* Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/lgpl.html
*/
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
#include <limits.h>
#ifdef USE_POLARSSL
#include <polarssl/dhm.h>
typedef mpi * MP_t;
#define MP_new(m) m = malloc(sizeof(mpi)); mpi_init(m)
#define MP_set_w(mpi, w) mpi_lset(mpi, w)
#define MP_cmp(u, v) mpi_cmp_mpi(u, v)
#define MP_set(u, v) mpi_copy(u, v)
#define MP_sub_w(mpi, w) mpi_sub_int(mpi, mpi, w)
#define MP_cmp_1(mpi) mpi_cmp_int(mpi, 1)
#define MP_modexp(r, y, q, p) mpi_exp_mod(r, y, q, p, NULL)
#define MP_free(mpi) mpi_free(mpi); free(mpi)
#define MP_gethex(u, hex, res) MP_new(u); res = mpi_read_string(u, 16, hex) == 0
#define MP_bytes(u) mpi_size(u)
#define MP_setbin(u,buf,len) mpi_write_binary(u,buf,len)
#define MP_getbin(u,buf,len) MP_new(u); mpi_read_binary(u,buf,len)
#define MP_setpg(dh, p, g) dh->p = p; dh->g = g
#define MP_setlength(dh, l) dh->length = l
#define MP_getp(dh) dh->p
#define MP_getpubkey(dh) dh->pub_key
typedef struct MDH {
MP_t p;
MP_t g;
MP_t pub_key;
MP_t priv_key;
long length;
dhm_context ctx;
} MDH;
#define MDH_new() calloc(1,sizeof(MDH))
#define MDH_free(vp) {MDH *_dh = vp; dhm_free(&_dh->ctx); MP_free(_dh->p); MP_free(_dh->g); MP_free(_dh->pub_key); MP_free(_dh->priv_key); free(_dh);}
static int MDH_generate_key(MDH *dh)
{
unsigned char out[2];
MP_set(&dh->ctx.P, dh->p);
MP_set(&dh->ctx.G, dh->g);
dh->ctx.len = 128;
dhm_make_public(&dh->ctx, 1024, out, 1, havege_random, &RTMP_TLS_ctx->hs);
MP_new(dh->pub_key);
MP_new(dh->priv_key);
MP_set(dh->pub_key, &dh->ctx.GX);
MP_set(dh->priv_key, &dh->ctx.X);
return 1;
}
static int MDH_compute_key(uint8_t *secret, size_t len, MP_t pub, MDH *dh)
{
MP_set(&dh->ctx.GY, pub);
dhm_calc_secret(&dh->ctx, secret, &len);
return 0;
}
#elif defined(USE_GNUTLS)
#include <gmp.h>
#include <nettle/bignum.h>
#include <gnutls/crypto.h>
typedef mpz_ptr MP_t;
#define MP_new(m) m = malloc(sizeof(*m)); mpz_init2(m, 1)
#define MP_set_w(mpi, w) mpz_set_ui(mpi, w)
#define MP_cmp(u, v) mpz_cmp(u, v)
#define MP_set(u, v) mpz_set(u, v)
#define MP_sub_w(mpi, w) mpz_sub_ui(mpi, mpi, w)
#define MP_cmp_1(mpi) mpz_cmp_ui(mpi, 1)
#define MP_modexp(r, y, q, p) mpz_powm(r, y, q, p)
#define MP_free(mpi) mpz_clear(mpi); free(mpi)
#define MP_gethex(u, hex, res) u = malloc(sizeof(*u)); mpz_init2(u, 1); res = (mpz_set_str(u, hex, 16) == 0)
#define MP_bytes(u) (mpz_sizeinbase(u, 2) + 7) / 8
#define MP_setbin(u,buf,len) nettle_mpz_get_str_256(len,buf,u)
#define MP_getbin(u,buf,len) u = malloc(sizeof(*u)); mpz_init2(u, 1); nettle_mpz_set_str_256_u(u,len,buf)
#define MP_setpg(dh, p, g) dh->p = p; dh->g = g
#define MP_setlength(dh, l) dh->length = l
#define MP_getp(dh) dh->p
#define MP_getpubkey(dh) dh->pub_key
typedef struct MDH {
MP_t p;
MP_t g;
MP_t pub_key;
MP_t priv_key;
long length;
} MDH;
#define MDH_new() calloc(1,sizeof(MDH))
#define MDH_free(dh) do {MP_free(((MDH*)(dh))->p); MP_free(((MDH*)(dh))->g); MP_free(((MDH*)(dh))->pub_key); MP_free(((MDH*)(dh))->priv_key); free(dh);} while(0)
static int MDH_generate_key(MDH *dh)
{
int num_bytes;
uint32_t seed;
gmp_randstate_t rs;
num_bytes = (mpz_sizeinbase(dh->p, 2) + 7) / 8 - 1;
if (num_bytes <= 0 || num_bytes > 18000)
return 0;
dh->priv_key = calloc(1, sizeof(*dh->priv_key));
if (!dh->priv_key)
return 0;
mpz_init2(dh->priv_key, 1);
gnutls_rnd(GNUTLS_RND_RANDOM, &seed, sizeof(seed));
gmp_randinit_mt(rs);
gmp_randseed_ui(rs, seed);
mpz_urandomb(dh->priv_key, rs, num_bytes);
gmp_randclear(rs);
dh->pub_key = calloc(1, sizeof(*dh->pub_key));
if (!dh->pub_key)
return 0;
mpz_init2(dh->pub_key, 1);
if (!dh->pub_key) {
mpz_clear(dh->priv_key);
free(dh->priv_key);
return 0;
}
mpz_powm(dh->pub_key, dh->g, dh->priv_key, dh->p);
return 1;
}
static int MDH_compute_key(uint8_t *secret, size_t len, MP_t pub, MDH *dh)
{
mpz_ptr k;
int num_bytes;
num_bytes = (mpz_sizeinbase(dh->p, 2) + 7) / 8;
if (num_bytes <= 0 || num_bytes > 18000)
return -1;
k = calloc(1, sizeof(*k));
if (!k)
return -1;
mpz_init2(k, 1);
mpz_powm(k, pub, dh->priv_key, dh->p);
nettle_mpz_get_str_256(len, secret, k);
mpz_clear(k);
free(k);
/* return the length of the shared secret key like DH_compute_key */
return len;
}
#else /* USE_OPENSSL */
#include <openssl/bn.h>
#include <openssl/dh.h>
typedef BIGNUM * MP_t;
#define MP_new(m) m = BN_new()
#define MP_set_w(mpi, w) BN_set_word(mpi, w)
#define MP_cmp(u, v) BN_cmp(u, v)
#define MP_set(u, v) BN_copy(u, v)
#define MP_sub_w(mpi, w) BN_sub_word(mpi, w)
#define MP_cmp_1(mpi) BN_cmp(mpi, BN_value_one())
#define MP_modexp(r, y, q, p) do {BN_CTX *ctx = BN_CTX_new(); BN_mod_exp(r, y, q, p, ctx); BN_CTX_free(ctx);} while(0)
#define MP_free(mpi) BN_free(mpi)
#define MP_gethex(u, hex, res) res = BN_hex2bn(&u, hex)
#define MP_bytes(u) BN_num_bytes(u)
#define MP_setbin(u,buf,len) BN_bn2bin(u,buf)
#define MP_getbin(u,buf,len) u = BN_bin2bn(buf,len,0)
#define MDH DH
#define MDH_new() DH_new()
#define MDH_free(dh) DH_free(dh)
#define MDH_generate_key(dh) DH_generate_key(dh)
#define MDH_compute_key(secret, seclen, pub, dh) DH_compute_key(secret, pub, dh)
#if OPENSSL_VERSION_NUMBER >= 0x10100000
#define MP_setpg(dh, p, g) DH_set0_pqg(dh, p, NULL, g)
#define MP_setlength(dh, l) DH_set_length(dh, l)
#define MP_getp(dh) DH_get0_p(dh)
#define MP_getpubkey(dh) DH_get0_pub_key(dh)
#else
#define MP_setpg(dh, p, g) dh->p = p; dh->g = g
#define MP_setlength(dh, l) dh->length = l
#define MP_getp(dh) dh->p
#define MP_getpubkey(dh) dh->pub_key
#endif
#endif
#include "log.h"
#include "dhgroups.h"
/* RFC 2631, Section 2.1.5, http://www.ietf.org/rfc/rfc2631.txt */
static int
isValidPublicKey(MP_t y, MP_t p, MP_t q)
{
int ret = TRUE;
MP_t bn;
assert(y);
MP_new(bn);
assert(bn);
/* y must lie in [2,p-1] */
MP_set_w(bn, 1);
if (MP_cmp(y, bn) < 0)
{
RTMP_Log(RTMP_LOGERROR, "DH public key must be at least 2");
ret = FALSE;
goto failed;
}
/* bn = p-2 */
MP_set(bn, p);
MP_sub_w(bn, 1);
if (MP_cmp(y, bn) > 0)
{
RTMP_Log(RTMP_LOGERROR, "DH public key must be at most p-2");
ret = FALSE;
goto failed;
}
/* Verify with Sophie-Germain prime
*
* This is a nice test to make sure the public key position is calculated
* correctly. This test will fail in about 50% of the cases if applied to
* random data.
*/
if (q)
{
/* y must fulfill y^q mod p = 1 */
MP_modexp(bn, y, q, p);
if (MP_cmp_1(bn) != 0)
{
RTMP_Log(RTMP_LOGWARNING, "DH public key does not fulfill y^q mod p = 1");
}
}
failed:
MP_free(bn);
return ret;
}
static MDH *
DHInit(int nKeyBits)
{
size_t res;
MDH *dh = MDH_new();
MP_t g, p;
if (!dh)
goto failed;
MP_new(g);
if (!g)
goto failed;
MP_gethex(p, P1024, res); /* prime P1024, see dhgroups.h */
if (!res)
{
goto failed;
}
MP_set_w(g, 2); /* base 2 */
MP_setpg(dh, p, g);
MP_setlength(dh, nKeyBits);
return dh;
failed:
if (dh)
MDH_free(dh);
return 0;
}
static int
DHGenerateKey(MDH *dh)
{
MP_t q1;
size_t res;
if (!dh)
return 0;
MP_gethex(q1, Q1024, res);
assert(res);
do
{
if (MDH_generate_key(dh))
{
MP_t key = (MP_t)MP_getpubkey(dh);
MP_t p = (MP_t)MP_getp(dh);
res = isValidPublicKey(key, p, q1);
}
else
{
#if !defined(OPENSSL_VERSION_NUMBER) || OPENSSL_VERSION_NUMBER < 0x10100000
MP_free(dh->pub_key);
MP_free(dh->priv_key);
dh->pub_key = dh->priv_key = 0;
#endif
res = 0;
break;
}
} while (!res);
MP_free(q1);
return res;
}
/* fill pubkey with the public key in BIG ENDIAN order
* 00 00 00 00 00 x1 x2 x3 .....
*/
static int
DHGetPublicKey(MDH *dh, uint8_t *pubkey, size_t nPubkeyLen)
{
int len;
MP_t pub_key;
if (!dh || !(pub_key = (MP_t)MP_getpubkey(dh)))
return 0;
len = MP_bytes(pub_key);
if (len <= 0 || len > (int) nPubkeyLen)
return 0;
memset(pubkey, 0, nPubkeyLen);
MP_setbin(pub_key, pubkey + (nPubkeyLen - len), len);
return 1;
}
#if 0 /* unused */
static int
DHGetPrivateKey(MDH *dh, uint8_t *privkey, size_t nPrivkeyLen)
{
if (!dh || !dh->priv_key)
return 0;
int len = MP_bytes(dh->priv_key);
if (len <= 0 || len > (int) nPrivkeyLen)
return 0;
memset(privkey, 0, nPrivkeyLen);
MP_setbin(dh->priv_key, privkey + (nPrivkeyLen - len), len);
return 1;
}
#endif
/* computes the shared secret key from the private MDH value and the
* other party's public key (pubkey)
*/
static int
DHComputeSharedSecretKey(MDH *dh, uint8_t *pubkey, size_t nPubkeyLen,
uint8_t *secret)
{
MP_t q1 = NULL, pubkeyBn = NULL;
size_t len;
int res;
if (!dh || !secret || nPubkeyLen >= INT_MAX)
return -1;
MP_getbin(pubkeyBn, pubkey, nPubkeyLen);
if (!pubkeyBn)
return -1;
MP_gethex(q1, Q1024, len);
assert(len);
if (isValidPublicKey(pubkeyBn, (MP_t)MP_getp(dh), q1))
res = MDH_compute_key(secret, nPubkeyLen, pubkeyBn, dh);
else
res = -1;
MP_free(q1);
MP_free(pubkeyBn);
return res;
}

View File

@@ -0,0 +1,199 @@
/* librtmp - Diffie-Hellmann Key Exchange
* Copyright (C) 2009 Andrej Stepanchuk
*
* This file is part of librtmp.
*
* librtmp is free software; you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation; either version 2.1,
* or (at your option) any later version.
*
* librtmp is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with librtmp see the file COPYING. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
* Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/lgpl.html
*/
/* from RFC 3526, see http://www.ietf.org/rfc/rfc3526.txt */
/* 2^768 - 2 ^704 - 1 + 2^64 * { [2^638 pi] + 149686 } */
#define P768 \
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" \
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD" \
"EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" \
"E485B576625E7EC6F44C42E9A63A3620FFFFFFFFFFFFFFFF"
/* 2^1024 - 2^960 - 1 + 2^64 * { [2^894 pi] + 129093 } */
#define P1024 \
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" \
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD" \
"EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" \
"E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" \
"EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381" \
"FFFFFFFFFFFFFFFF"
/* Group morder largest prime factor: */
#define Q1024 \
"7FFFFFFFFFFFFFFFE487ED5110B4611A62633145C06E0E68" \
"948127044533E63A0105DF531D89CD9128A5043CC71A026E" \
"F7CA8CD9E69D218D98158536F92F8A1BA7F09AB6B6A8E122" \
"F242DABB312F3F637A262174D31BF6B585FFAE5B7A035BF6" \
"F71C35FDAD44CFD2D74F9208BE258FF324943328F67329C0" \
"FFFFFFFFFFFFFFFF"
/* 2^1536 - 2^1472 - 1 + 2^64 * { [2^1406 pi] + 741804 } */
#define P1536 \
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" \
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD" \
"EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" \
"E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" \
"EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" \
"C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" \
"83655D23DCA3AD961C62F356208552BB9ED529077096966D" \
"670C354E4ABC9804F1746C08CA237327FFFFFFFFFFFFFFFF"
/* 2^2048 - 2^1984 - 1 + 2^64 * { [2^1918 pi] + 124476 } */
#define P2048 \
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" \
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD" \
"EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" \
"E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" \
"EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" \
"C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" \
"83655D23DCA3AD961C62F356208552BB9ED529077096966D" \
"670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" \
"E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9" \
"DE2BCBF6955817183995497CEA956AE515D2261898FA0510" \
"15728E5A8AACAA68FFFFFFFFFFFFFFFF"
/* 2^3072 - 2^3008 - 1 + 2^64 * { [2^2942 pi] + 1690314 } */
#define P3072 \
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" \
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD" \
"EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" \
"E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" \
"EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" \
"C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" \
"83655D23DCA3AD961C62F356208552BB9ED529077096966D" \
"670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" \
"E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9" \
"DE2BCBF6955817183995497CEA956AE515D2261898FA0510" \
"15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64" \
"ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" \
"ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B" \
"F12FFA06D98A0864D87602733EC86A64521F2B18177B200C" \
"BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31" \
"43DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF"
/* 2^4096 - 2^4032 - 1 + 2^64 * { [2^3966 pi] + 240904 } */
#define P4096 \
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" \
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD" \
"EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" \
"E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" \
"EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" \
"C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" \
"83655D23DCA3AD961C62F356208552BB9ED529077096966D" \
"670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" \
"E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9" \
"DE2BCBF6955817183995497CEA956AE515D2261898FA0510" \
"15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64" \
"ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" \
"ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B" \
"F12FFA06D98A0864D87602733EC86A64521F2B18177B200C" \
"BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31" \
"43DB5BFCE0FD108E4B82D120A92108011A723C12A787E6D7" \
"88719A10BDBA5B2699C327186AF4E23C1A946834B6150BDA" \
"2583E9CA2AD44CE8DBBBC2DB04DE8EF92E8EFC141FBECAA6" \
"287C59474E6BC05D99B2964FA090C3A2233BA186515BE7ED" \
"1F612970CEE2D7AFB81BDD762170481CD0069127D5B05AA9" \
"93B4EA988D8FDDC186FFB7DC90A6C08F4DF435C934063199" \
"FFFFFFFFFFFFFFFF"
/* 2^6144 - 2^6080 - 1 + 2^64 * { [2^6014 pi] + 929484 } */
#define P6144 \
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" \
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD" \
"EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" \
"E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" \
"EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" \
"C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" \
"83655D23DCA3AD961C62F356208552BB9ED529077096966D" \
"670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" \
"E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9" \
"DE2BCBF6955817183995497CEA956AE515D2261898FA0510" \
"15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64" \
"ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" \
"ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B" \
"F12FFA06D98A0864D87602733EC86A64521F2B18177B200C" \
"BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31" \
"43DB5BFCE0FD108E4B82D120A92108011A723C12A787E6D7" \
"88719A10BDBA5B2699C327186AF4E23C1A946834B6150BDA" \
"2583E9CA2AD44CE8DBBBC2DB04DE8EF92E8EFC141FBECAA6" \
"287C59474E6BC05D99B2964FA090C3A2233BA186515BE7ED" \
"1F612970CEE2D7AFB81BDD762170481CD0069127D5B05AA9" \
"93B4EA988D8FDDC186FFB7DC90A6C08F4DF435C934028492" \
"36C3FAB4D27C7026C1D4DCB2602646DEC9751E763DBA37BD" \
"F8FF9406AD9E530EE5DB382F413001AEB06A53ED9027D831" \
"179727B0865A8918DA3EDBEBCF9B14ED44CE6CBACED4BB1B" \
"DB7F1447E6CC254B332051512BD7AF426FB8F401378CD2BF" \
"5983CA01C64B92ECF032EA15D1721D03F482D7CE6E74FEF6" \
"D55E702F46980C82B5A84031900B1C9E59E7C97FBEC7E8F3" \
"23A97A7E36CC88BE0F1D45B7FF585AC54BD407B22B4154AA" \
"CC8F6D7EBF48E1D814CC5ED20F8037E0A79715EEF29BE328" \
"06A1D58BB7C5DA76F550AA3D8A1FBFF0EB19CCB1A313D55C" \
"DA56C9EC2EF29632387FE8D76E3C0468043E8F663F4860EE" \
"12BF2D5B0B7474D6E694F91E6DCC4024FFFFFFFFFFFFFFFF"
/* 2^8192 - 2^8128 - 1 + 2^64 * { [2^8062 pi] + 4743158 } */
#define P8192 \
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" \
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD" \
"EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" \
"E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" \
"EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" \
"C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" \
"83655D23DCA3AD961C62F356208552BB9ED529077096966D" \
"670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" \
"E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9" \
"DE2BCBF6955817183995497CEA956AE515D2261898FA0510" \
"15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64" \
"ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" \
"ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B" \
"F12FFA06D98A0864D87602733EC86A64521F2B18177B200C" \
"BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31" \
"43DB5BFCE0FD108E4B82D120A92108011A723C12A787E6D7" \
"88719A10BDBA5B2699C327186AF4E23C1A946834B6150BDA" \
"2583E9CA2AD44CE8DBBBC2DB04DE8EF92E8EFC141FBECAA6" \
"287C59474E6BC05D99B2964FA090C3A2233BA186515BE7ED" \
"1F612970CEE2D7AFB81BDD762170481CD0069127D5B05AA9" \
"93B4EA988D8FDDC186FFB7DC90A6C08F4DF435C934028492" \
"36C3FAB4D27C7026C1D4DCB2602646DEC9751E763DBA37BD" \
"F8FF9406AD9E530EE5DB382F413001AEB06A53ED9027D831" \
"179727B0865A8918DA3EDBEBCF9B14ED44CE6CBACED4BB1B" \
"DB7F1447E6CC254B332051512BD7AF426FB8F401378CD2BF" \
"5983CA01C64B92ECF032EA15D1721D03F482D7CE6E74FEF6" \
"D55E702F46980C82B5A84031900B1C9E59E7C97FBEC7E8F3" \
"23A97A7E36CC88BE0F1D45B7FF585AC54BD407B22B4154AA" \
"CC8F6D7EBF48E1D814CC5ED20F8037E0A79715EEF29BE328" \
"06A1D58BB7C5DA76F550AA3D8A1FBFF0EB19CCB1A313D55C" \
"DA56C9EC2EF29632387FE8D76E3C0468043E8F663F4860EE" \
"12BF2D5B0B7474D6E694F91E6DBE115974A3926F12FEE5E4" \
"38777CB6A932DF8CD8BEC4D073B931BA3BC832B68D9DD300" \
"741FA7BF8AFC47ED2576F6936BA424663AAB639C5AE4F568" \
"3423B4742BF1C978238F16CBE39D652DE3FDB8BEFC848AD9" \
"22222E04A4037C0713EB57A81A23F0C73473FC646CEA306B" \
"4BCBC8862F8385DDFA9D4B7FA2C087E879683303ED5BDD3A" \
"062B3CF5B3A278A66D2A13F83F44F82DDF310EE074AB6A36" \
"4597E899A0255DC164F31CC50846851DF9AB48195DED7EA1" \
"B1D510BD7EE74D73FAF36BC31ECFA268359046F4EB879F92" \
"4009438B481C6CD7889A002ED5EE382BC9190DA6FC026E47" \
"9558E4475677E9AA9E3050E2765694DFC81F56E880B96E71" \
"60C980DD98EDD3DFFFFFFFFFFFFFFFFF"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
#ifndef __RTMP_HTTP_H__
#define __RTMP_HTTP_H__
/*
* Copyright (C) 2010 Howard Chu
* Copyright (C) 2010 Antti Ajanki
*
* This file is part of librtmp.
*
* librtmp is free software; you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation; either version 2.1,
* or (at your option) any later version.
*
* librtmp is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with librtmp see the file COPYING. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
* Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/lgpl.html
*/
typedef enum {
HTTPRES_OK, /* result OK */
HTTPRES_OK_NOT_MODIFIED, /* not modified since last request */
HTTPRES_NOT_FOUND, /* not found */
HTTPRES_BAD_REQUEST, /* client error */
HTTPRES_SERVER_ERROR, /* server reported an error */
HTTPRES_REDIRECTED, /* resource has been moved */
HTTPRES_LOST_CONNECTION /* connection lost while waiting for data */
} HTTPResult;
struct HTTP_ctx {
char *date;
int size;
int status;
void *data;
};
typedef size_t (HTTP_read_callback)(void *ptr, size_t size, size_t nmemb, void *stream);
HTTPResult HTTP_get(struct HTTP_ctx *http, const char *url, HTTP_read_callback *cb);
#endif

View File

@@ -0,0 +1,69 @@
/*
* Copyright (C) 2008-2009 Andrej Stepanchuk
* Copyright (C) 2009-2010 Howard Chu
*
* This file is part of librtmp.
*
* librtmp is free software; you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation; either version 2.1,
* or (at your option) any later version.
*
* librtmp is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with librtmp see the file COPYING. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
* Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/lgpl.html
*/
#ifndef __RTMP_LOG_H__
#define __RTMP_LOG_H__
#include <stdio.h>
#include <stdarg.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Enable this to get full debugging output */
/* #define _DEBUG */
#ifdef _DEBUG
#undef NODEBUG
#endif
typedef enum
{ RTMP_LOGCRIT=0, RTMP_LOGERROR, RTMP_LOGWARNING, RTMP_LOGINFO,
RTMP_LOGDEBUG, RTMP_LOGDEBUG2, RTMP_LOGALL
} RTMP_LogLevel;
extern RTMP_LogLevel RTMP_debuglevel;
typedef void (RTMP_LogCallback)(int level, const char *fmt, va_list);
void RTMP_LogSetCallback(RTMP_LogCallback *cb);
void RTMP_LogSetOutput(FILE *file);
#ifdef __GNUC__
void RTMP_LogPrintf(const char *format, ...) __attribute__ ((__format__ (__printf__, 1, 2)));
void RTMP_LogStatus(const char *format, ...) __attribute__ ((__format__ (__printf__, 1, 2)));
void RTMP_Log(int level, const char *format, ...) __attribute__ ((__format__ (__printf__, 2, 3)));
#else
void RTMP_LogPrintf(const char *format, ...);
void RTMP_LogStatus(const char *format, ...);
void RTMP_Log(int level, const char *format, ...);
#endif
void RTMP_LogHex(int level, const uint8_t *data, unsigned long len);
void RTMP_LogHexString(int level, const uint8_t *data, unsigned long len);
void RTMP_LogSetLevel(RTMP_LogLevel lvl);
RTMP_LogLevel RTMP_LogGetLevel(void);
#ifdef __cplusplus
}
#endif
#endif

View File

@@ -0,0 +1,378 @@
#ifndef __RTMP_H__
#define __RTMP_H__
/*
* Copyright (C) 2005-2008 Team XBMC
* http://www.xbmc.org
* Copyright (C) 2008-2009 Andrej Stepanchuk
* Copyright (C) 2009-2010 Howard Chu
*
* This file is part of librtmp.
*
* librtmp is free software; you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation; either version 2.1,
* or (at your option) any later version.
*
* librtmp is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with librtmp see the file COPYING. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
* Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/lgpl.html
*/
#if !defined(NO_CRYPTO) && !defined(CRYPTO)
#define CRYPTO
#endif
#include <errno.h>
#include <stdint.h>
#include <stddef.h>
#include "amf.h"
#ifdef __cplusplus
extern "C"
{
#endif
#define RTMP_LIB_VERSION 0x020300 /* 2.3 */
#define RTMP_FEATURE_HTTP 0x01
#define RTMP_FEATURE_ENC 0x02
#define RTMP_FEATURE_SSL 0x04
#define RTMP_FEATURE_MFP 0x08 /* not yet supported */
#define RTMP_FEATURE_WRITE 0x10 /* publish, not play */
#define RTMP_FEATURE_HTTP2 0x20 /* server-side rtmpt */
#define RTMP_PROTOCOL_UNDEFINED -1
#define RTMP_PROTOCOL_RTMP 0
#define RTMP_PROTOCOL_RTMPE RTMP_FEATURE_ENC
#define RTMP_PROTOCOL_RTMPT RTMP_FEATURE_HTTP
#define RTMP_PROTOCOL_RTMPS RTMP_FEATURE_SSL
#define RTMP_PROTOCOL_RTMPTE (RTMP_FEATURE_HTTP|RTMP_FEATURE_ENC)
#define RTMP_PROTOCOL_RTMPTS (RTMP_FEATURE_HTTP|RTMP_FEATURE_SSL)
#define RTMP_PROTOCOL_RTMFP RTMP_FEATURE_MFP
#define RTMP_DEFAULT_CHUNKSIZE 128
/* needs to fit largest number of bytes recv() may return */
#define RTMP_BUFFER_CACHE_SIZE (16*1024)
#define RTMP_CHANNELS 65600
extern const char RTMPProtocolStringsLower[][7];
extern const AVal RTMP_DefaultFlashVer;
extern int RTMP_ctrlC;
uint32_t RTMP_GetTime(void);
/* RTMP_PACKET_TYPE_... 0x00 */
#define RTMP_PACKET_TYPE_CHUNK_SIZE 0x01
/* RTMP_PACKET_TYPE_... 0x02 */
#define RTMP_PACKET_TYPE_BYTES_READ_REPORT 0x03
#define RTMP_PACKET_TYPE_CONTROL 0x04
#define RTMP_PACKET_TYPE_SERVER_BW 0x05
#define RTMP_PACKET_TYPE_CLIENT_BW 0x06
/* RTMP_PACKET_TYPE_... 0x07 */
#define RTMP_PACKET_TYPE_AUDIO 0x08
#define RTMP_PACKET_TYPE_VIDEO 0x09
/* RTMP_PACKET_TYPE_... 0x0A */
/* RTMP_PACKET_TYPE_... 0x0B */
/* RTMP_PACKET_TYPE_... 0x0C */
/* RTMP_PACKET_TYPE_... 0x0D */
/* RTMP_PACKET_TYPE_... 0x0E */
#define RTMP_PACKET_TYPE_FLEX_STREAM_SEND 0x0F
#define RTMP_PACKET_TYPE_FLEX_SHARED_OBJECT 0x10
#define RTMP_PACKET_TYPE_FLEX_MESSAGE 0x11
#define RTMP_PACKET_TYPE_INFO 0x12
#define RTMP_PACKET_TYPE_SHARED_OBJECT 0x13
#define RTMP_PACKET_TYPE_INVOKE 0x14
/* RTMP_PACKET_TYPE_... 0x15 */
#define RTMP_PACKET_TYPE_FLASH_VIDEO 0x16
#define RTMP_MAX_HEADER_SIZE 18
#define RTMP_PACKET_SIZE_LARGE 0
#define RTMP_PACKET_SIZE_MEDIUM 1
#define RTMP_PACKET_SIZE_SMALL 2
#define RTMP_PACKET_SIZE_MINIMUM 3
typedef struct RTMPChunk
{
int c_headerSize;
int c_chunkSize;
char *c_chunk;
char c_header[RTMP_MAX_HEADER_SIZE];
} RTMPChunk;
typedef struct RTMPPacket
{
uint8_t m_headerType;
uint8_t m_packetType;
uint8_t m_hasAbsTimestamp; /* timestamp absolute or relative? */
int m_nChannel;
uint32_t m_nTimeStamp; /* timestamp */
int32_t m_nInfoField2; /* last 4 bytes in a long header */
uint32_t m_nBodySize;
uint32_t m_nBytesRead;
RTMPChunk *m_chunk;
char *m_body;
} RTMPPacket;
typedef struct RTMPSockBuf
{
int sb_socket;
int sb_size; /* number of unprocessed bytes in buffer */
char *sb_start; /* pointer into sb_pBuffer of next byte to process */
char sb_buf[RTMP_BUFFER_CACHE_SIZE]; /* data read from socket */
int sb_timedout;
void *sb_ssl;
} RTMPSockBuf;
void RTMPPacket_Reset(RTMPPacket *p);
void RTMPPacket_Dump(RTMPPacket *p);
int RTMPPacket_Alloc(RTMPPacket *p, uint32_t nSize);
void RTMPPacket_Free(RTMPPacket *p);
#define RTMPPacket_IsReady(a) ((a)->m_nBytesRead == (a)->m_nBodySize)
typedef struct RTMP_LNK
{
AVal hostname;
AVal sockshost;
AVal playpath0; /* parsed from URL */
AVal playpath; /* passed in explicitly */
AVal tcUrl;
AVal swfUrl;
AVal pageUrl;
AVal app;
AVal auth;
AVal flashVer;
AVal subscribepath;
AVal usherToken;
AVal token;
AVal pubUser;
AVal pubPasswd;
AMFObject extras;
int edepth;
int seekTime;
int stopTime;
#define RTMP_LF_AUTH 0x0001 /* using auth param */
#define RTMP_LF_LIVE 0x0002 /* stream is live */
#define RTMP_LF_SWFV 0x0004 /* do SWF verification */
#define RTMP_LF_PLST 0x0008 /* send playlist before play */
#define RTMP_LF_BUFX 0x0010 /* toggle stream on BufferEmpty msg */
#define RTMP_LF_FTCU 0x0020 /* free tcUrl on close */
#define RTMP_LF_FAPU 0x0040 /* free app on close */
int lFlags;
int swfAge;
int protocol;
int timeout; /* connection timeout in seconds */
int pFlags; /* unused, but kept to avoid breaking ABI */
unsigned short socksport;
unsigned short port;
#ifdef CRYPTO
#define RTMP_SWF_HASHLEN 32
void *dh; /* for encryption */
void *rc4keyIn;
void *rc4keyOut;
uint32_t SWFSize;
uint8_t SWFHash[RTMP_SWF_HASHLEN];
char SWFVerificationResponse[RTMP_SWF_HASHLEN+10];
#endif
} RTMP_LNK;
/* state for read() wrapper */
typedef struct RTMP_READ
{
char *buf;
char *bufpos;
unsigned int buflen;
uint32_t timestamp;
uint8_t dataType;
uint8_t flags;
#define RTMP_READ_HEADER 0x01
#define RTMP_READ_RESUME 0x02
#define RTMP_READ_NO_IGNORE 0x04
#define RTMP_READ_GOTKF 0x08
#define RTMP_READ_GOTFLVK 0x10
#define RTMP_READ_SEEKING 0x20
int8_t status;
#define RTMP_READ_COMPLETE -3
#define RTMP_READ_ERROR -2
#define RTMP_READ_EOF -1
#define RTMP_READ_IGNORE 0
/* if bResume == TRUE */
uint8_t initialFrameType;
uint32_t nResumeTS;
char *metaHeader;
char *initialFrame;
uint32_t nMetaHeaderSize;
uint32_t nInitialFrameSize;
uint32_t nIgnoredFrameCounter;
uint32_t nIgnoredFlvFrameCounter;
} RTMP_READ;
typedef struct RTMP_METHOD
{
AVal name;
int num;
} RTMP_METHOD;
typedef struct RTMP
{
int m_inChunkSize;
int m_outChunkSize;
int m_nBWCheckCounter;
int m_nBytesIn;
int m_nBytesInSent;
int m_nBufferMS;
int m_stream_id; /* returned in _result from createStream */
int m_mediaChannel;
uint32_t m_mediaStamp;
uint32_t m_pauseStamp;
int m_pausing;
int m_nServerBW;
int m_nClientBW;
uint8_t m_nClientBW2;
uint8_t m_bPlaying;
uint8_t m_bSendEncoding;
uint8_t m_bSendCounter;
int m_numInvokes;
int m_numCalls;
RTMP_METHOD *m_methodCalls; /* remote method calls queue */
int m_channelsAllocatedIn;
int m_channelsAllocatedOut;
RTMPPacket **m_vecChannelsIn;
RTMPPacket **m_vecChannelsOut;
int *m_channelTimestamp; /* abs timestamp of last packet */
double m_fAudioCodecs; /* audioCodecs for the connect packet */
double m_fVideoCodecs; /* videoCodecs for the connect packet */
double m_fEncoding; /* AMF0 or AMF3 */
double m_fDuration; /* duration of stream in seconds */
int m_msgCounter; /* RTMPT stuff */
int m_polling;
int m_resplen;
int m_unackd;
AVal m_clientID;
RTMP_READ m_read;
RTMPPacket m_write;
RTMPSockBuf m_sb;
RTMP_LNK Link;
} RTMP;
int RTMP_ParseURL(const char *url, int *protocol, AVal *host,
unsigned int *port, AVal *playpath, AVal *app);
void RTMP_ParsePlaypath(AVal *in, AVal *out);
void RTMP_SetBufferMS(RTMP *r, int size);
void RTMP_UpdateBufferMS(RTMP *r);
int RTMP_SetOpt(RTMP *r, const AVal *opt, AVal *arg);
int RTMP_SetupURL(RTMP *r, char *url);
void RTMP_SetupStream(RTMP *r, int protocol,
AVal *hostname,
unsigned int port,
AVal *sockshost,
AVal *playpath,
AVal *tcUrl,
AVal *swfUrl,
AVal *pageUrl,
AVal *app,
AVal *auth,
AVal *swfSHA256Hash,
uint32_t swfSize,
AVal *flashVer,
AVal *subscribepath,
AVal *usherToken,
int dStart,
int dStop, int bLiveStream, long int timeout);
int RTMP_Connect(RTMP *r, RTMPPacket *cp);
struct sockaddr;
int RTMP_Connect0(RTMP *r, struct sockaddr *svc);
int RTMP_Connect1(RTMP *r, RTMPPacket *cp);
int RTMP_Serve(RTMP *r);
int RTMP_TLS_Accept(RTMP *r, void *ctx);
int RTMP_ReadPacket(RTMP *r, RTMPPacket *packet);
int RTMP_SendPacket(RTMP *r, RTMPPacket *packet, int queue);
int RTMP_SendChunk(RTMP *r, RTMPChunk *chunk);
int RTMP_IsConnected(RTMP *r);
int RTMP_Socket(RTMP *r);
int RTMP_IsTimedout(RTMP *r);
double RTMP_GetDuration(RTMP *r);
int RTMP_ToggleStream(RTMP *r);
int RTMP_ConnectStream(RTMP *r, int seekTime);
int RTMP_ReconnectStream(RTMP *r, int seekTime);
void RTMP_DeleteStream(RTMP *r);
int RTMP_GetNextMediaPacket(RTMP *r, RTMPPacket *packet);
int RTMP_ClientPacket(RTMP *r, RTMPPacket *packet);
void RTMP_Init(RTMP *r);
void RTMP_Close(RTMP *r);
RTMP *RTMP_Alloc(void);
void RTMP_Free(RTMP *r);
void RTMP_EnableWrite(RTMP *r);
void *RTMP_TLS_AllocServerContext(const char* cert, const char* key);
void RTMP_TLS_FreeServerContext(void *ctx);
int RTMP_LibVersion(void);
void RTMP_UserInterrupt(void); /* user typed Ctrl-C */
int RTMP_SendCtrl(RTMP *r, short nType, unsigned int nObject,
unsigned int nTime);
/* caller probably doesn't know current timestamp, should
* just use RTMP_Pause instead
*/
int RTMP_SendPause(RTMP *r, int DoPause, int dTime);
int RTMP_Pause(RTMP *r, int DoPause);
int RTMP_FindFirstMatchingProperty(AMFObject *obj, const AVal *name,
AMFObjectProperty * p);
int RTMPSockBuf_Fill(RTMPSockBuf *sb);
int RTMPSockBuf_Send(RTMPSockBuf *sb, const char *buf, int len);
int RTMPSockBuf_Close(RTMPSockBuf *sb);
int RTMP_SendCreateStream(RTMP *r);
int RTMP_SendSeek(RTMP *r, int dTime);
int RTMP_SendServerBW(RTMP *r);
int RTMP_SendClientBW(RTMP *r);
void RTMP_DropRequest(RTMP *r, int i, int freeit);
int RTMP_Read(RTMP *r, char *buf, int size);
int RTMP_Write(RTMP *r, const char *buf, int size);
/* hashswf.c */
int RTMP_HashSWF(const char *url, unsigned int *size, unsigned char *hash,
int age);
#ifdef __cplusplus
};
#endif
#endif

View File

@@ -0,0 +1,141 @@
#ifndef __RTMP_SYS_H__
#define __RTMP_SYS_H__
/*
* Copyright (C) 2010 Howard Chu
*
* This file is part of librtmp.
*
* librtmp is free software; you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation; either version 2.1,
* or (at your option) any later version.
*
* librtmp is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with librtmp see the file COPYING. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
* Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/lgpl.html
*/
#ifdef _WIN32
#include <winsock2.h>
#include <ws2tcpip.h>
#ifdef _MSC_VER /* MSVC */
#if _MSC_VER < 1900
#define snprintf _snprintf
#define vsnprintf _vsnprintf
#endif
#define strcasecmp _stricmp
#define strncasecmp _strnicmp
#endif
#define GetSockError() WSAGetLastError()
#define SetSockError(e) WSASetLastError(e)
#define setsockopt(a,b,c,d,e) (setsockopt)(a,b,c,(const char *)d,(int)e)
#define EWOULDBLOCK WSAETIMEDOUT /* we don't use nonblocking, but we do use timeouts */
#define sleep(n) Sleep(n*1000)
#define msleep(n) Sleep(n)
#define SET_RCVTIMEO(tv,s) int tv = s*1000
#else /* !_WIN32 */
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/times.h>
#include <netdb.h>
#include <unistd.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#define GetSockError() errno
#define SetSockError(e) errno = e
#undef closesocket
#define closesocket(s) close(s)
#define msleep(n) usleep(n*1000)
#define SET_RCVTIMEO(tv,s) struct timeval tv = {s,0}
#endif
#include "rtmp.h"
#ifdef USE_POLARSSL
#include <polarssl/version.h>
#include <polarssl/net.h>
#include <polarssl/ssl.h>
#include <polarssl/havege.h>
#if POLARSSL_VERSION_NUMBER < 0x01010000
#define havege_random havege_rand
#endif
#if POLARSSL_VERSION_NUMBER >= 0x01020000
#define SSL_SET_SESSION(S,resume,timeout,ctx) ssl_set_session(S,ctx)
#else
#define SSL_SET_SESSION(S,resume,timeout,ctx) ssl_set_session(S,resume,timeout,ctx)
#endif
typedef struct tls_ctx {
havege_state hs;
ssl_session ssn;
} tls_ctx;
typedef struct tls_server_ctx {
havege_state *hs;
x509_cert cert;
rsa_context key;
ssl_session ssn;
const char *dhm_P, *dhm_G;
} tls_server_ctx;
#define TLS_CTX tls_ctx *
#define TLS_client(ctx,s) s = malloc(sizeof(ssl_context)); ssl_init(s);\
ssl_set_endpoint(s, SSL_IS_CLIENT); ssl_set_authmode(s, SSL_VERIFY_NONE);\
ssl_set_rng(s, havege_random, &ctx->hs);\
ssl_set_ciphersuites(s, ssl_default_ciphersuites);\
SSL_SET_SESSION(s, 1, 600, &ctx->ssn)
#define TLS_server(ctx,s) s = malloc(sizeof(ssl_context)); ssl_init(s);\
ssl_set_endpoint(s, SSL_IS_SERVER); ssl_set_authmode(s, SSL_VERIFY_NONE);\
ssl_set_rng(s, havege_random, ((tls_server_ctx*)ctx)->hs);\
ssl_set_ciphersuites(s, ssl_default_ciphersuites);\
SSL_SET_SESSION(s, 1, 600, &((tls_server_ctx*)ctx)->ssn);\
ssl_set_own_cert(s, &((tls_server_ctx*)ctx)->cert, &((tls_server_ctx*)ctx)->key);\
ssl_set_dh_param(s, ((tls_server_ctx*)ctx)->dhm_P, ((tls_server_ctx*)ctx)->dhm_G)
#define TLS_setfd(s,fd) ssl_set_bio(s, net_recv, &fd, net_send, &fd)
#define TLS_connect(s) ssl_handshake(s)
#define TLS_accept(s) ssl_handshake(s)
#define TLS_read(s,b,l) ssl_read(s,(unsigned char *)b,l)
#define TLS_write(s,b,l) ssl_write(s,(unsigned char *)b,l)
#define TLS_shutdown(s) ssl_close_notify(s)
#define TLS_close(s) ssl_free(s); free(s)
#elif defined(USE_GNUTLS)
#include <gnutls/gnutls.h>
typedef struct tls_ctx {
gnutls_certificate_credentials_t cred;
gnutls_priority_t prios;
} tls_ctx;
#define TLS_CTX tls_ctx *
#define TLS_client(ctx,s) gnutls_init((gnutls_session_t *)(&s), GNUTLS_CLIENT); gnutls_priority_set(s, ctx->prios); gnutls_credentials_set(s, GNUTLS_CRD_CERTIFICATE, ctx->cred)
#define TLS_server(ctx,s) gnutls_init((gnutls_session_t *)(&s), GNUTLS_SERVER); gnutls_priority_set_direct(s, "NORMAL", NULL); gnutls_credentials_set(s, GNUTLS_CRD_CERTIFICATE, ctx)
#define TLS_setfd(s,fd) gnutls_transport_set_ptr(s, (gnutls_transport_ptr_t)(long)fd)
#define TLS_connect(s) gnutls_handshake(s)
#define TLS_accept(s) gnutls_handshake(s)
#define TLS_read(s,b,l) gnutls_record_recv(s,b,l)
#define TLS_write(s,b,l) gnutls_record_send(s,b,l)
#define TLS_shutdown(s) gnutls_bye(s, GNUTLS_SHUT_RDWR)
#define TLS_close(s) gnutls_deinit(s)
#else /* USE_OPENSSL */
#define TLS_CTX SSL_CTX *
#define TLS_client(ctx,s) s = SSL_new(ctx)
#define TLS_server(ctx,s) s = SSL_new(ctx)
#define TLS_setfd(s,fd) SSL_set_fd(s,fd)
#define TLS_connect(s) SSL_connect(s)
#define TLS_accept(s) SSL_accept(s)
#define TLS_read(s,b,l) SSL_read(s,b,l)
#define TLS_write(s,b,l) SSL_write(s,b,l)
#define TLS_shutdown(s) SSL_shutdown(s)
#define TLS_close(s) SSL_free(s)
#endif
#endif

View File

@@ -1,7 +1,14 @@
package com.omixlab.lckcontrol
import android.app.Application
import android.content.Intent
import com.omixlab.lckcontrol.service.LckControlService
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class LckControlApp : Application()
class LckControlApp : Application() {
override fun onCreate() {
super.onCreate()
startForegroundService(Intent(this, LckControlService::class.java))
}
}

View File

@@ -4,6 +4,7 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.omixlab.lckcontrol.chat.ChatNotificationManager
import com.omixlab.lckcontrol.data.local.TokenStore
import com.omixlab.lckcontrol.data.remote.LckApiService
import com.omixlab.lckcontrol.ui.navigation.AppNavigation
@@ -16,6 +17,7 @@ class MainActivity : ComponentActivity() {
@Inject lateinit var tokenStore: TokenStore
@Inject lateinit var apiService: LckApiService
@Inject lateinit var chatNotificationManager: ChatNotificationManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -26,4 +28,14 @@ class MainActivity : ComponentActivity() {
}
}
}
override fun onResume() {
super.onResume()
chatNotificationManager.setAppForeground(true)
}
override fun onPause() {
super.onPause()
chatNotificationManager.setAppForeground(false)
}
}

View File

@@ -0,0 +1,71 @@
package com.omixlab.lckcontrol.chat
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import com.omixlab.lckcontrol.R
import com.omixlab.lckcontrol.data.remote.ChatMessageEvent
import com.omixlab.lckcontrol.data.repository.ChatRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ChatNotificationManager @Inject constructor(
@ApplicationContext private val context: Context,
private val chatRepository: ChatRepository,
) {
companion object {
private const val CHANNEL_ID = "lck_chat_messages"
private const val NOTIFICATION_TAG = "chat_msg"
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val notificationManager = context.getSystemService(NotificationManager::class.java)
private var notificationIdCounter = 100
private var appInForeground = true
fun init() {
createNotificationChannel()
scope.launch {
chatRepository.latestMessage.collect { event ->
if (!appInForeground) {
showNotification(event)
}
}
}
}
fun setAppForeground(foreground: Boolean) {
appInForeground = foreground
}
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
"Chat Messages",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Live chat messages from YouTube and Twitch"
}
notificationManager.createNotificationChannel(channel)
}
private fun showNotification(event: ChatMessageEvent) {
val title = if (event.service == "YOUTUBE") "YouTube Chat" else "Twitch Chat"
val body = "${event.message.authorName}: ${event.message.text}"
val notification = android.app.Notification.Builder(context, CHANNEL_ID)
.setContentTitle(title)
.setContentText(body)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setAutoCancel(true)
.build()
notificationManager.notify(NOTIFICATION_TAG, notificationIdCounter++, notification)
}
}

View File

@@ -0,0 +1,337 @@
package com.omixlab.lckcontrol.cortex
import android.content.Context
import android.graphics.Bitmap
import android.hardware.HardwareBuffer
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.media.MediaMuxer
import android.util.Log
import com.omixlab.lckcontrol.streaming.NativeStreamingEngine
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CortexManager @Inject constructor(
@ApplicationContext private val context: Context,
private val cortexPreferences: CortexPreferences,
) {
companion object {
private const val TAG = "CortexManager"
private const val CORTEX_DIR = "cortex"
private const val VIDEO_BITRATE = 8_000_000
private const val AUDIO_BITRATE = 128_000
private const val SAMPLE_RATE = 48_000
private const val CHANNELS = 2
private const val KEYFRAME_INTERVAL = 2
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var cortexEngine: NativeStreamingEngine? = null
private var currentSessionId: String? = null
private var poolWidth = 0
private var poolHeight = 0
private val _sessions = MutableStateFlow<List<CortexSession>>(emptyList())
val sessions: StateFlow<List<CortexSession>> = _sessions.asStateFlow()
private val _isRecording = MutableStateFlow(false)
val isRecording: StateFlow<Boolean> = _isRecording.asStateFlow()
private val _storageUsedBytes = MutableStateFlow(0L)
val storageUsedBytes: StateFlow<Long> = _storageUsedBytes.asStateFlow()
var onBufferReleased: ((Int) -> Unit)? = null
val hasCortexEngine: Boolean get() = cortexEngine != null
private fun cortexBaseDir(): File = File(context.filesDir, CORTEX_DIR)
fun onTexturePoolRegistered(width: Int, height: Int) {
poolWidth = width
poolHeight = height
if (cortexPreferences.isEnabled()) {
startCortexOnlyEngine()
}
}
fun onTexturePoolUnregistered() {
stopCortexEngine()
poolWidth = 0
poolHeight = 0
}
fun onStreamingStarting(engine: NativeStreamingEngine) {
// Stop cortex-only engine — streaming engine takes over
stopCortexEngine()
// Enable cortex recording on the streaming engine
if (cortexPreferences.isEnabled()) {
val sessionDir = getOrCreateSessionDir()
engine.enableCortexRecording(sessionDir.absolutePath, cortexPreferences.getMaxMinutes())
engine.onCortexSegment = { segPath, keyframeData ->
scope.launch { handleSegmentReady(segPath, keyframeData) }
}
_isRecording.value = true
Log.i(TAG, "Cortex enabled on streaming engine: ${sessionDir.absolutePath}")
}
}
fun onStreamingStopped() {
// Restart cortex-only engine if we have a texture pool and cortex is enabled
if (poolWidth > 0 && poolHeight > 0 && cortexPreferences.isEnabled()) {
startCortexOnlyEngine()
}
}
fun submitVideoFrame(buffer: HardwareBuffer, timestampNs: Long, fenceFd: Int, bufferIndex: Int) {
cortexEngine?.submitVideoFrame(buffer, timestampNs, fenceFd, bufferIndex)
}
fun submitAudioFrame(pcmData: ByteArray, timestampNs: Long) {
cortexEngine?.submitAudioFrame(pcmData, timestampNs)
}
fun deleteSession(sessionId: String) {
val dir = File(cortexBaseDir(), sessionId)
if (dir.exists()) {
dir.deleteRecursively()
}
refreshSessions()
}
fun deleteAllSessions() {
// Don't delete the current active session
val activeId = currentSessionId
cortexBaseDir().listFiles()?.forEach { dir ->
if (dir.isDirectory && dir.name != activeId) {
dir.deleteRecursively()
}
}
refreshSessions()
}
fun refreshSessions() {
scope.launch {
val baseDir = cortexBaseDir()
if (!baseDir.exists()) {
_sessions.value = emptyList()
_storageUsedBytes.value = 0L
return@launch
}
var totalSize = 0L
val sessionList = baseDir.listFiles()
?.filter { it.isDirectory }
?.mapNotNull { dir ->
val segFiles = dir.listFiles { f -> f.name.endsWith(".seg") } ?: emptyArray()
if (segFiles.isEmpty()) return@mapNotNull null
val thumbnails = dir.listFiles { f -> f.name.endsWith(".jpg") }
?.sortedBy { it.name }
?.map { it.absolutePath }
?: emptyList()
val size = dir.listFiles()?.sumOf { it.length() } ?: 0L
totalSize += size
CortexSession(
sessionId = dir.name,
directory = dir,
segmentCount = segFiles.size,
thumbnailPaths = thumbnails,
totalSizeBytes = size,
startTime = segFiles.minOf { it.lastModified() },
)
}
?.sortedByDescending { it.startTime }
?: emptyList()
_sessions.value = sessionList
_storageUsedBytes.value = totalSize
}
}
private fun startCortexOnlyEngine() {
if (cortexEngine != null) return
if (poolWidth <= 0 || poolHeight <= 0) return
Log.i(TAG, "Starting cortex-only engine: ${poolWidth}x${poolHeight}")
val eng = NativeStreamingEngine()
eng.create(
width = poolWidth,
height = poolHeight,
videoBitrate = VIDEO_BITRATE,
audioBitrate = AUDIO_BITRATE,
sampleRate = SAMPLE_RATE,
channels = CHANNELS,
keyframeInterval = KEYFRAME_INTERVAL,
)
// No addDestination calls — zero sinks
eng.onBufferReleased = { index ->
onBufferReleased?.invoke(index)
}
val sessionDir = getOrCreateSessionDir()
eng.enableCortexRecording(sessionDir.absolutePath, cortexPreferences.getMaxMinutes())
eng.onCortexSegment = { segPath, keyframeData ->
scope.launch { handleSegmentReady(segPath, keyframeData) }
}
if (eng.start()) {
cortexEngine = eng
_isRecording.value = true
Log.i(TAG, "Cortex-only engine started")
} else {
eng.destroy()
Log.e(TAG, "Failed to start cortex-only engine")
}
}
private fun stopCortexEngine() {
val eng = cortexEngine ?: return
Log.i(TAG, "Stopping cortex-only engine")
eng.disableCortexRecording()
eng.stop()
eng.destroy()
cortexEngine = null
_isRecording.value = false
refreshSessions()
}
private fun getOrCreateSessionDir(): File {
val id = currentSessionId ?: UUID.randomUUID().toString().also { currentSessionId = it }
val dir = File(cortexBaseDir(), id)
dir.mkdirs()
return dir
}
private suspend fun handleSegmentReady(segPath: String, keyframeData: ByteArray) {
Log.i(TAG, "Segment ready: $segPath")
generateThumbnail(segPath, keyframeData)
refreshSessions()
}
private fun generateThumbnail(segPath: String, keyframeData: ByteArray) {
try {
val segFile = File(segPath)
val segName = segFile.nameWithoutExtension // e.g. seg_000000
val segIndex = segName.removePrefix("seg_")
val thumbFile = File(segFile.parentFile, "thumb_$segIndex.jpg")
// Mux the single keyframe into a temp MP4 so MediaMetadataRetriever can decode it
val tempMp4 = File(segFile.parentFile, "thumb_temp_$segIndex.mp4")
try {
val muxer = MediaMuxer(tempMp4.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, poolWidth, poolHeight)
// Parse SPS/PPS from the .seg file header
// For now, use the keyframe data directly — the codec config should be set on the format
// We need to read the SPS/PPS from the segment file
val spsPps = readSpsPpsFromSegment(segPath)
if (spsPps != null) {
// Split SPS/PPS (Annex-B: [00 00 00 01 SPS] [00 00 00 01 PPS])
val startCode = byteArrayOf(0, 0, 0, 1)
var ppsOffset = -1
for (i in 4 until spsPps.size - 3) {
if (spsPps[i] == 0.toByte() && spsPps[i + 1] == 0.toByte() &&
spsPps[i + 2] == 0.toByte() && spsPps[i + 3] == 1.toByte()
) {
ppsOffset = i
break
}
}
if (ppsOffset > 0) {
format.setByteBuffer("csd-0", java.nio.ByteBuffer.wrap(spsPps, 0, ppsOffset))
format.setByteBuffer("csd-1", java.nio.ByteBuffer.wrap(spsPps, ppsOffset, spsPps.size - ppsOffset))
} else {
format.setByteBuffer("csd-0", java.nio.ByteBuffer.wrap(spsPps))
}
}
val trackIndex = muxer.addTrack(format)
muxer.start()
val bufferInfo = MediaCodec.BufferInfo().apply {
offset = 0
size = keyframeData.size
presentationTimeUs = 0
flags = MediaCodec.BUFFER_FLAG_KEY_FRAME
}
muxer.writeSampleData(trackIndex, java.nio.ByteBuffer.wrap(keyframeData), bufferInfo)
muxer.stop()
muxer.release()
// Extract frame with MediaMetadataRetriever
val retriever = MediaMetadataRetriever()
retriever.setDataSource(tempMp4.absolutePath)
val bitmap = retriever.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
retriever.release()
if (bitmap != null) {
FileOutputStream(thumbFile).use { fos ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, fos)
}
bitmap.recycle()
Log.i(TAG, "Thumbnail generated: ${thumbFile.absolutePath}")
}
} finally {
tempMp4.delete()
}
} catch (e: Exception) {
Log.e(TAG, "Failed to generate thumbnail for $segPath", e)
}
}
private fun readSpsPpsFromSegment(segPath: String): ByteArray? {
try {
val file = File(segPath)
val raf = java.io.RandomAccessFile(file, "r")
// Magic(4) + version(4) + width(4) + height(4) = 16 bytes
raf.seek(16)
val spsPpsSize = Integer.reverseBytes(raf.readInt()).let {
// The file is written in native byte order (little-endian on ARM)
// RandomAccessFile.readInt() reads big-endian, so we need to handle this
// Actually, our writeVal writes raw bytes, so we should read raw bytes too
raf.seek(16)
val buf = ByteArray(4)
raf.read(buf)
(buf[0].toInt() and 0xFF) or
((buf[1].toInt() and 0xFF) shl 8) or
((buf[2].toInt() and 0xFF) shl 16) or
((buf[3].toInt() and 0xFF) shl 24)
}
if (spsPpsSize <= 0 || spsPpsSize > 1024) {
raf.close()
return null
}
val data = ByteArray(spsPpsSize)
raf.read(data)
raf.close()
return data
} catch (e: Exception) {
Log.e(TAG, "Failed to read SPS/PPS from segment", e)
return null
}
}
}

View File

@@ -0,0 +1,32 @@
package com.omixlab.lckcontrol.cortex
import android.content.Context
import android.content.SharedPreferences
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CortexPreferences @Inject constructor(
@ApplicationContext context: Context,
) {
private val prefs: SharedPreferences =
context.getSharedPreferences("lck_cortex_prefs", Context.MODE_PRIVATE)
fun isEnabled(): Boolean = prefs.getBoolean(KEY_ENABLED, false)
fun setEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_ENABLED, enabled).apply()
}
fun getMaxMinutes(): Int = prefs.getInt(KEY_MAX_MINUTES, 10)
fun setMaxMinutes(minutes: Int) {
prefs.edit().putInt(KEY_MAX_MINUTES, minutes).apply()
}
private companion object {
const val KEY_ENABLED = "cortex_enabled"
const val KEY_MAX_MINUTES = "cortex_max_minutes"
}
}

View File

@@ -0,0 +1,12 @@
package com.omixlab.lckcontrol.cortex
import java.io.File
data class CortexSession(
val sessionId: String,
val directory: File,
val segmentCount: Int,
val thumbnailPaths: List<String>,
val totalSizeBytes: Long,
val startTime: Long,
)

View File

@@ -0,0 +1,26 @@
package com.omixlab.lckcontrol.data.local
import android.content.Context
import android.content.SharedPreferences
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AppPreferences @Inject constructor(
@ApplicationContext context: Context,
) {
private val prefs: SharedPreferences =
context.getSharedPreferences("lck_control_app_prefs", Context.MODE_PRIVATE)
fun getDefaultExecutionMode(): String =
prefs.getString(KEY_DEFAULT_EXECUTION_MODE, "IN_GAME") ?: "IN_GAME"
fun setDefaultExecutionMode(mode: String) {
prefs.edit().putString(KEY_DEFAULT_EXECUTION_MODE, mode).apply()
}
private companion object {
const val KEY_DEFAULT_EXECUTION_MODE = "default_execution_mode"
}
}

View File

@@ -16,7 +16,7 @@ import com.omixlab.lckcontrol.data.local.entity.StreamPlanEntity
StreamPlanEntity::class,
StreamDestinationEntity::class,
],
version = 3,
version = 7,
exportSchema = false,
)
abstract class LckDatabase : RoomDatabase() {
@@ -96,5 +96,31 @@ abstract class LckDatabase : RoomDatabase() {
db.execSQL("ALTER TABLE stream_destinations ADD COLUMN linkedAccountId TEXT NOT NULL DEFAULT ''")
}
}
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE stream_plans ADD COLUMN executionMode TEXT NOT NULL DEFAULT 'IN_GAME'")
}
}
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE linked_accounts ADD COLUMN isEnabled INTEGER NOT NULL DEFAULT 1")
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")
}
}
val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE stream_plans ADD COLUMN isPublic INTEGER NOT NULL DEFAULT 1")
}
}
}
}

View File

@@ -33,4 +33,7 @@ interface LinkedAccountDao {
@Query("DELETE FROM linked_accounts WHERE serviceId = :serviceId")
suspend fun deleteByService(serviceId: String)
@Query("UPDATE linked_accounts SET isEnabled = :isEnabled WHERE id = :id")
suspend fun setEnabled(id: String, isEnabled: Boolean)
}

View File

@@ -10,4 +10,7 @@ data class LinkedAccountEntity(
val displayName: String,
val accountId: String,
val avatarUrl: String? = null,
val isEnabled: Boolean = true,
val rtmpUrl: String? = null,
val streamKey: String? = null,
)

View File

@@ -8,5 +8,8 @@ data class StreamPlanEntity(
@PrimaryKey val planId: String,
val name: String,
val status: String = "DRAFT",
val executionMode: String = "IN_GAME",
val gameId: String = "",
val isPublic: Boolean = true,
val createdAt: Long = System.currentTimeMillis(),
)

View File

@@ -8,6 +8,7 @@ import com.squareup.moshi.JsonClass
data class HealthResponse(
val status: String,
val timestamp: String,
val version: String? = null,
)
// ── Auth ─────────────────────────────────────────────────
@@ -37,6 +38,28 @@ data class UserProfileResponse(
val displayName: String,
val email: String?,
val avatarUrl: String?,
val bio: String = "",
val isPublic: Boolean = false,
)
@JsonClass(generateAdapter = true)
data class PairingCodeResponse(
val code: String,
val expiresAt: String,
)
@JsonClass(generateAdapter = true)
data class PairingStatusResponse(
val active: Boolean,
val code: String? = null,
val expiresAt: String? = null,
)
@JsonClass(generateAdapter = true)
data class UpdateProfileRequest(
val displayName: String? = null,
val bio: String? = null,
val isPublic: Boolean? = null,
)
// ── Providers ────────────────────────────────────────────
@@ -60,6 +83,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 ──────────────────────────────────────────────
@@ -67,17 +99,31 @@ data class LinkedAccountResponse(
@JsonClass(generateAdapter = true)
data class CreateStreamPlanRequest(
val name: String,
val executionMode: String? = null,
val gameId: String? = null,
val isPublic: Boolean? = null,
val destinations: List<CreateDestinationRequest>,
)
@JsonClass(generateAdapter = true)
data class UpdateStreamPlanRequest(
val name: String? = null,
val executionMode: String? = null,
val gameId: String? = null,
val isPublic: Boolean? = null,
val destinations: List<CreateDestinationRequest>? = null,
)
@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)
@@ -85,6 +131,9 @@ data class StreamPlanResponse(
val id: String,
val name: String,
val status: String,
val executionMode: String? = null,
val gameId: String? = null,
val isPublic: Boolean = true,
val createdAt: String,
val updatedAt: String,
val destinations: List<StreamDestinationResponse>,

View File

@@ -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")
}
}

View File

@@ -0,0 +1,63 @@
package com.omixlab.lckcontrol.data.remote
import com.squareup.moshi.JsonClass
// ── Incoming events from WebSocket ──────────────────────
@JsonClass(generateAdapter = true)
data class ChatMessageEvent(
val type: String,
val planId: String,
val service: String,
val destinationId: String,
val message: ChatMessage,
)
@JsonClass(generateAdapter = true)
data class ChatMessage(
val id: String,
val authorName: String,
val authorImageUrl: String? = null,
val text: String,
val timestamp: Long,
val isModerator: Boolean = false,
val isBroadcaster: Boolean = false,
val color: String? = null,
)
@JsonClass(generateAdapter = true)
data class ChatStatusEvent(
val type: String,
val planId: String,
val service: String? = null,
val destinationId: String? = null,
val connected: Boolean? = null,
val error: String? = null,
)
// ── Outgoing commands to WebSocket ──────────────────────
@JsonClass(generateAdapter = true)
data class SubscribeCommand(
val type: String = "subscribe",
val planId: String,
)
@JsonClass(generateAdapter = true)
data class UnsubscribeCommand(
val type: String = "unsubscribe",
val planId: String,
)
@JsonClass(generateAdapter = true)
data class SendChatCommand(
val type: String = "send_message",
val planId: String,
val destinationId: String,
val text: String,
)
data class ChatConnectionStatus(
val connected: Boolean,
val error: String? = null,
)

View File

@@ -1,5 +1,6 @@
package com.omixlab.lckcontrol.data.remote
import okhttp3.MultipartBody
import retrofit2.http.*
interface LckApiService {
@@ -23,6 +24,15 @@ interface LckApiService {
@POST("auth/logout")
suspend fun logout(): SuccessResponse
@PATCH("auth/me")
suspend fun updateProfile(@Body body: UpdateProfileRequest): UserProfileResponse
@POST("auth/pairing/generate")
suspend fun generatePairingCode(): PairingCodeResponse
@GET("auth/pairing/status")
suspend fun getPairingStatus(): PairingStatusResponse
// ── Providers ────────────────────────────────────────
@GET("providers/accounts")
@@ -40,6 +50,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
@@ -54,6 +67,9 @@ interface LckApiService {
@GET("streams/plans/{id}")
suspend fun getStreamPlan(@Path("id") id: String): StreamPlanResponse
@PUT("streams/plans/{id}")
suspend fun updateStreamPlan(@Path("id") id: String, @Body body: UpdateStreamPlanRequest): StreamPlanResponse
@DELETE("streams/plans/{id}")
suspend fun deleteStreamPlan(@Path("id") id: String): SuccessResponse
@@ -65,4 +81,13 @@ interface LckApiService {
@POST("streams/plans/{id}/end")
suspend fun endStreamPlan(@Path("id") id: String): StatusResponse
// ── Preview ──────────────────────────────────────────
@Multipart
@POST("streams/plans/{id}/preview")
suspend fun uploadPreview(
@Path("id") planId: String,
@Part preview: MultipartBody.Part,
)
}

View File

@@ -2,6 +2,7 @@ package com.omixlab.lckcontrol.data.repository
import com.omixlab.lckcontrol.data.local.dao.LinkedAccountDao
import com.omixlab.lckcontrol.data.local.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
@@ -26,6 +27,8 @@ class AccountRepository @Inject constructor(
/** Fetch accounts from backend and sync to Room cache */
suspend fun syncAccounts() {
val remote = apiService.getLinkedAccounts()
// Read local entities to preserve isEnabled across sync
val localMap = accountDao.getAll().associateBy { it.id }
val entities = remote.map { account ->
LinkedAccountEntity(
id = account.id,
@@ -33,12 +36,14 @@ class AccountRepository @Inject constructor(
displayName = account.displayName,
accountId = account.accountId,
avatarUrl = account.avatarUrl,
isEnabled = localMap[account.id]?.isEnabled ?: true,
rtmpUrl = account.rtmpUrl,
streamKey = account.streamKey,
)
}
// Get current local accounts to detect removals
val local = accountDao.getAll()
// Detect removals
val remoteIds = entities.map { it.id }.toSet()
for (localAccount in local) {
for (localAccount in localMap.values) {
if (localAccount.id !in remoteIds) {
accountDao.deleteById(localAccount.id)
}
@@ -48,6 +53,16 @@ class AccountRepository @Inject constructor(
}
}
suspend fun setAccountEnabled(id: String, enabled: Boolean) {
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()
@@ -85,5 +100,8 @@ class AccountRepository @Inject constructor(
accountId = accountId,
avatarUrl = avatarUrl,
isAuthenticated = true, // Backend manages auth state
isEnabled = isEnabled,
rtmpUrl = rtmpUrl,
streamKey = streamKey,
)
}

View File

@@ -0,0 +1,228 @@
package com.omixlab.lckcontrol.data.repository
import android.util.Log
import com.omixlab.lckcontrol.data.local.TokenStore
import com.omixlab.lckcontrol.data.remote.ChatConnectionStatus
import com.omixlab.lckcontrol.data.remote.ChatMessage
import com.omixlab.lckcontrol.data.remote.ChatMessageEvent
import com.omixlab.lckcontrol.data.remote.ChatStatusEvent
import com.omixlab.lckcontrol.data.remote.SendChatCommand
import com.omixlab.lckcontrol.data.remote.SubscribeCommand
import com.omixlab.lckcontrol.data.remote.UnsubscribeCommand
import com.squareup.moshi.Moshi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ChatRepository @Inject constructor(
private val okHttpClient: OkHttpClient,
private val tokenStore: TokenStore,
private val moshi: Moshi,
) {
companion object {
private const val TAG = "ChatRepository"
private const val WS_BASE_URL = "wss://lck.omigame.dev/chat/ws"
private const val MAX_MESSAGES_PER_DEST = 500
private const val MAX_RECONNECT_DELAY_MS = 30_000L
}
// Separate client for WebSocket with ping keepalive and no read timeout
private val wsClient = okHttpClient.newBuilder()
.pingInterval(30, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS)
.build()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// key: "planId:SERVICE:destinationId"
private val _messages = MutableStateFlow<Map<String, List<ChatMessage>>>(emptyMap())
val messages: StateFlow<Map<String, List<ChatMessage>>> = _messages.asStateFlow()
private val _connectionStatus = MutableStateFlow<Map<String, ChatConnectionStatus>>(emptyMap())
val connectionStatus: StateFlow<Map<String, ChatConnectionStatus>> = _connectionStatus.asStateFlow()
private val _unreadCounts = MutableStateFlow<Map<String, Int>>(emptyMap())
val unreadCounts: StateFlow<Map<String, Int>> = _unreadCounts.asStateFlow()
private val _latestMessage = MutableSharedFlow<ChatMessageEvent>(extraBufferCapacity = 64)
val latestMessage: SharedFlow<ChatMessageEvent> = _latestMessage.asSharedFlow()
private var webSocket: WebSocket? = null
private var activeViewKey: String? = null
private var reconnectAttempt = 0
private var shouldConnect = false
private val activeSubscriptions = mutableSetOf<String>()
private val messageAdapter = moshi.adapter(ChatMessageEvent::class.java)
private val statusAdapter = moshi.adapter(ChatStatusEvent::class.java)
private val subscribeAdapter = moshi.adapter(SubscribeCommand::class.java)
private val unsubscribeAdapter = moshi.adapter(UnsubscribeCommand::class.java)
private val sendAdapter = moshi.adapter(SendChatCommand::class.java)
fun connect() {
shouldConnect = true
doConnect()
}
fun disconnect() {
shouldConnect = false
activeSubscriptions.clear()
webSocket?.close(1000, "App disconnecting")
webSocket = null
}
fun subscribe(planId: String) {
if (!activeSubscriptions.add(planId)) return // already subscribed
val cmd = SubscribeCommand(planId = planId)
val sent = webSocket?.send(subscribeAdapter.toJson(cmd))
Log.d(TAG, "subscribe($planId) wsConnected=${webSocket != null} sent=$sent")
}
fun unsubscribe(planId: String) {
activeSubscriptions.remove(planId)
val cmd = UnsubscribeCommand(planId = planId)
webSocket?.send(unsubscribeAdapter.toJson(cmd))
}
fun sendMessage(planId: String, destinationId: String, text: String) {
val cmd = SendChatCommand(planId = planId, destinationId = destinationId, text = text)
webSocket?.send(sendAdapter.toJson(cmd))
}
fun setActiveView(key: String) {
activeViewKey = key
clearUnread(key)
}
fun clearActiveView() {
activeViewKey = null
}
fun clearUnread(key: String) {
_unreadCounts.value = _unreadCounts.value.toMutableMap().apply { remove(key) }
}
private fun doConnect() {
if (webSocket != null) return
val jwt = tokenStore.getJwt() ?: return
val request = Request.Builder()
.url("$WS_BASE_URL?token=$jwt")
.build()
webSocket = wsClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d(TAG, "WebSocket connected")
reconnectAttempt = 0
// Re-subscribe to all active plans
for (planId in activeSubscriptions) {
Log.d(TAG, "Re-subscribing to plan $planId")
val cmd = SubscribeCommand(planId = planId)
webSocket.send(subscribeAdapter.toJson(cmd))
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
handleMessage(text)
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
webSocket.close(1000, null)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.d(TAG, "WebSocket closed: $code $reason")
this@ChatRepository.webSocket = null
scheduleReconnect()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.e(TAG, "WebSocket failure", t)
this@ChatRepository.webSocket = null
scheduleReconnect()
}
})
}
private fun scheduleReconnect() {
if (!shouldConnect) return
val delayMs = minOf(1000L * (1 shl reconnectAttempt.coerceAtMost(5)), MAX_RECONNECT_DELAY_MS)
reconnectAttempt++
Log.d(TAG, "Reconnecting in ${delayMs}ms (attempt $reconnectAttempt)")
scope.launch {
delay(delayMs)
if (shouldConnect && webSocket == null) {
doConnect()
}
}
}
private fun handleMessage(text: String) {
try {
Log.d(TAG, "WS message received: ${text.take(200)}")
// Peek at type field to dispatch
val json = moshi.adapter(Map::class.java).fromJson(text) ?: return
when (json["type"]) {
"chat_message" -> {
val event = messageAdapter.fromJson(text) ?: return
val key = "${event.planId}:${event.service}:${event.destinationId}"
// Append to messages (ring buffer capped at MAX_MESSAGES_PER_DEST)
_messages.value = _messages.value.toMutableMap().apply {
val existing = this[key] ?: emptyList()
val updated = if (existing.size >= MAX_MESSAGES_PER_DEST) {
existing.drop(1) + event.message
} else {
existing + event.message
}
this[key] = updated
}
// Increment unread if not actively viewing
if (activeViewKey != key) {
_unreadCounts.value = _unreadCounts.value.toMutableMap().apply {
this[key] = (this[key] ?: 0) + 1
}
}
// Emit for notifications
_latestMessage.tryEmit(event)
}
"chat_status" -> {
val event = statusAdapter.fromJson(text) ?: return
if (event.service != null && event.destinationId != null) {
val key = "${event.planId}:${event.service}:${event.destinationId}"
_connectionStatus.value = _connectionStatus.value.toMutableMap().apply {
this[key] = ChatConnectionStatus(
connected = event.connected ?: false,
error = event.error,
)
}
}
}
"error" -> {
Log.e(TAG, "Server error: ${json["error"]}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to parse WebSocket message", e)
}
}
}

View File

@@ -9,6 +9,7 @@ import com.omixlab.lckcontrol.data.remote.CreateStreamPlanRequest
import com.omixlab.lckcontrol.data.remote.LckApiService
import com.omixlab.lckcontrol.data.remote.PrepareResponse
import com.omixlab.lckcontrol.data.remote.StreamPlanResponse
import com.omixlab.lckcontrol.data.remote.UpdateStreamPlanRequest
import com.omixlab.lckcontrol.shared.StreamDestination
import com.omixlab.lckcontrol.shared.StreamPlan
import kotlinx.coroutines.flow.Flow
@@ -42,17 +43,28 @@ class StreamPlanRepository @Inject constructor(
}
/** Create plan via backend and cache locally */
suspend fun createPlan(name: String, destinations: List<StreamDestination>): StreamPlan {
suspend fun createPlan(
name: String,
destinations: List<StreamDestination>,
executionMode: String = "IN_GAME",
gameId: String = "",
isPublic: Boolean = true,
): StreamPlan {
val request = CreateStreamPlanRequest(
name = name,
executionMode = executionMode,
gameId = gameId.ifBlank { null },
isPublic = isPublic,
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 },
)
},
)
@@ -61,6 +73,38 @@ class StreamPlanRepository @Inject constructor(
return planDao.getById(response.id)!!.toStreamPlan()
}
/** Update a DRAFT plan via backend and refresh local cache */
suspend fun updatePlan(
planId: String,
name: String,
destinations: List<StreamDestination>,
executionMode: String,
gameId: String,
isPublic: Boolean = true,
): StreamPlan {
val request = UpdateStreamPlanRequest(
name = name,
executionMode = executionMode,
gameId = gameId.ifBlank { null },
isPublic = isPublic,
destinations = destinations.map { dest ->
CreateDestinationRequest(
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 },
)
},
)
val response = apiService.updateStreamPlan(planId, request)
cacheRemotePlan(response)
return planDao.getById(response.id)!!.toStreamPlan()
}
/** Prepare plan via backend — returns RTMP info */
suspend fun preparePlan(planId: String): PrepareResponse {
val response = apiService.prepareStreamPlan(planId)
@@ -91,12 +135,23 @@ class StreamPlanRepository @Inject constructor(
}
suspend fun deletePlan(planId: String) {
apiService.deleteStreamPlan(planId)
try {
apiService.deleteStreamPlan(planId)
} catch (_: Exception) {
// Backend may have already deleted this plan (404) — still remove locally
}
planDao.deletePlan(planId)
}
private suspend fun cacheRemotePlan(remote: StreamPlanResponse) {
val planEntity = StreamPlanEntity(planId = remote.id, name = remote.name, status = remote.status)
val planEntity = StreamPlanEntity(
planId = remote.id,
name = remote.name,
status = remote.status,
executionMode = remote.executionMode ?: "IN_GAME",
gameId = remote.gameId ?: "",
isPublic = remote.isPublic,
)
val destEntities = remote.destinations.map { d ->
StreamDestinationEntity(
id = d.id,
@@ -121,6 +176,9 @@ class StreamPlanRepository @Inject constructor(
planId = plan.planId,
name = plan.name,
status = plan.status,
executionMode = plan.executionMode,
gameId = plan.gameId,
isPublic = plan.isPublic,
destinations = destinations.map { it.toStreamDestination() },
)

View File

@@ -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)
.addMigrations(LckDatabase.MIGRATION_1_2, LckDatabase.MIGRATION_2_3, LckDatabase.MIGRATION_3_4, LckDatabase.MIGRATION_4_5, LckDatabase.MIGRATION_5_6, LckDatabase.MIGRATION_6_7)
.build()
@Provides

View File

@@ -0,0 +1,86 @@
package com.omixlab.lckcontrol.p2p
import android.util.Log
import com.omixlab.lckcontrol.p2p.channels.ControlChannelHandler
import com.omixlab.lckcontrol.p2p.channels.FileChannelHandler
import com.omixlab.lckcontrol.p2p.discovery.NsdAdvertiser
import com.omixlab.lckcontrol.p2p.discovery.P2pPreferences
import com.omixlab.lckcontrol.p2p.webrtc.LanSignalingServer
import com.omixlab.lckcontrol.p2p.webrtc.RemoteSignalingClient
import com.omixlab.lckcontrol.p2p.webrtc.WebRtcPeerManager
import kotlinx.coroutines.CoroutineScope
import javax.inject.Inject
import javax.inject.Singleton
/**
* Orchestrates the complete P2P lifecycle:
* 1. NSD discovery/advertising
* 2. LAN signaling server (HTTP on port 8765)
* 3. Remote signaling (WebSocket to backend)
* 4. WebRTC peer connection
* 5. Control + file data channels
*/
@Singleton
class PeerSessionManager @Inject constructor(
private val nsdAdvertiser: NsdAdvertiser,
private val lanSignalingServer: LanSignalingServer,
private val remoteSignalingClient: RemoteSignalingClient,
private val webRtcPeerManager: WebRtcPeerManager,
private val controlHandler: ControlChannelHandler,
private val fileHandler: FileChannelHandler,
private val p2pPreferences: P2pPreferences,
) {
companion object {
private const val TAG = "PeerSessionManager"
}
fun start(userId: String, scope: CoroutineScope) {
Log.d(TAG, "Starting P2P session for user $userId")
// Initialize WebRTC
webRtcPeerManager.initialize()
// Wire up control and file handlers
webRtcPeerManager.onControlMessage = { json ->
controlHandler.handleMessage(json)
}
webRtcPeerManager.onFileData = { data ->
fileHandler.handleData(data)
}
// Start NSD advertising
nsdAdvertiser.startAdvertising(userId)
// Start LAN signaling server
lanSignalingServer.onOfferReceived = { sdp, candidates, nonce ->
Log.d(TAG, "Received LAN offer, creating answer")
webRtcPeerManager.handleOffer(sdp, candidates)
}
lanSignalingServer.start(scope)
// Connect to backend signaling
remoteSignalingClient.onOfferReceived = { from, sdp ->
Log.d(TAG, "Received remote offer from $from")
// Handle remote offer (create answer and send back)
val candidates = emptyList<com.omixlab.lckcontrol.p2p.webrtc.IceCandidateDto>()
val response = webRtcPeerManager.handleOffer(sdp, candidates)
response?.let {
remoteSignalingClient.sendAnswer(from, it.sdp)
}
}
remoteSignalingClient.connect()
}
fun stop() {
Log.d(TAG, "Stopping P2P session")
nsdAdvertiser.stopAdvertising()
lanSignalingServer.stop()
remoteSignalingClient.disconnect()
webRtcPeerManager.disconnect()
}
fun release() {
stop()
webRtcPeerManager.release()
}
}

View File

@@ -0,0 +1,143 @@
package com.omixlab.lckcontrol.p2p.channels
import android.content.Context
import android.os.BatteryManager
import android.util.Log
import com.omixlab.lckcontrol.p2p.webrtc.WebRtcPeerManager
import com.squareup.moshi.Moshi
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ControlChannelHandler @Inject constructor(
@ApplicationContext private val context: Context,
private val peerManager: WebRtcPeerManager,
private val moshi: Moshi,
) {
companion object {
private const val TAG = "ControlHandler"
}
fun handleMessage(json: String) {
try {
val msg = moshi.adapter(ControlMessage::class.java).fromJson(json)
if (msg?.type != "request") return
when (msg.method) {
"getDeviceStatus" -> handleGetDeviceStatus(msg.id)
"getStreamingStats" -> handleGetStreamingStats(msg.id)
"getCortexState" -> handleGetCortexState(msg.id)
"listFiles" -> handleListFiles(msg.id)
"startVideoStream" -> handleStartVideoStream(msg.id)
"stopVideoStream" -> handleStopVideoStream(msg.id)
else -> sendError(msg.id, "Unknown method: ${msg.method}")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to handle message", e)
}
}
private fun handleGetDeviceStatus(requestId: String) {
val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
val batteryLevel = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
val runtime = Runtime.getRuntime()
val memoryUsed = runtime.totalMemory() - runtime.freeMemory()
val memoryTotal = runtime.maxMemory()
sendResponse(requestId, "getDeviceStatus", mapOf(
"batteryLevel" to batteryLevel,
"memoryUsed" to memoryUsed,
"memoryTotal" to memoryTotal,
"runningGame" to null,
"streamingState" to null,
"cortexRecording" to false,
))
}
private fun handleGetStreamingStats(requestId: String) {
sendResponse(requestId, "getStreamingStats", mapOf(
"bitrate" to 0,
"fps" to 0,
"droppedFrames" to 0,
"duration" to 0,
))
}
private fun handleGetCortexState(requestId: String) {
sendResponse(requestId, "getCortexState", mapOf(
"recording" to false,
"storageUsed" to 0,
))
}
private fun handleListFiles(requestId: String) {
// List available clips and cortex files
val files = mutableListOf<Map<String, Any?>>()
val clipsDir = context.getExternalFilesDir("clips")
clipsDir?.listFiles()?.forEach { file ->
files.add(mapOf(
"path" to file.absolutePath,
"name" to file.name,
"size" to file.length(),
"isDirectory" to file.isDirectory,
))
}
val cortexDir = context.getExternalFilesDir("cortex")
cortexDir?.listFiles()?.forEach { file ->
files.add(mapOf(
"path" to file.absolutePath,
"name" to file.name,
"size" to file.length(),
"isDirectory" to file.isDirectory,
))
}
sendResponse(requestId, "listFiles", mapOf("files" to files))
}
private fun handleStartVideoStream(requestId: String) {
// TODO: Connect to EncodedVideoSource and add track to peer connection
sendResponse(requestId, "startVideoStream", mapOf("started" to true))
}
private fun handleStopVideoStream(requestId: String) {
// TODO: Stop video stream
sendResponse(requestId, "stopVideoStream", mapOf("stopped" to true))
}
fun sendEvent(method: String, payload: Map<String, Any?>) {
val msg = ControlMessage(
id = System.currentTimeMillis().toString(),
type = "event",
method = method,
payload = payload,
)
val json = moshi.adapter(ControlMessage::class.java).toJson(msg)
peerManager.sendControlMessage(json)
}
private fun sendResponse(requestId: String, method: String, payload: Map<String, Any?>) {
val msg = ControlMessage(
id = requestId,
type = "response",
method = method,
payload = payload,
)
val json = moshi.adapter(ControlMessage::class.java).toJson(msg)
peerManager.sendControlMessage(json)
}
private fun sendError(requestId: String, error: String) {
val msg = ControlMessage(
id = requestId,
type = "response",
error = error,
)
val json = moshi.adapter(ControlMessage::class.java).toJson(msg)
peerManager.sendControlMessage(json)
}
}

View File

@@ -0,0 +1,36 @@
package com.omixlab.lckcontrol.p2p.channels
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ControlMessage(
val id: String,
val type: String, // "request", "response", "event"
val method: String? = null,
val payload: Map<String, Any?>? = null,
val error: String? = null,
)
// Supported methods:
// Phone→Quest requests:
// getDeviceStatus, getStreamPlans, prepareStreamPlan, startStreamPlan,
// endStreamPlan, getStreamingStats, getLinkedAccounts, getCortexState,
// listFiles, startVideoStream, stopVideoStream
//
// Quest→Phone events:
// streamingStateChanged, planUpdated, cortexSessionUpdate
// File channel binary protocol:
// [type:u8][transferId:16B][payload]
object FileProtocol {
const val FILE_LIST_REQUEST: Byte = 0x01
const val FILE_LIST_RESPONSE: Byte = 0x02
const val FILE_REQUEST: Byte = 0x03
const val FILE_HEADER: Byte = 0x04
const val FILE_CHUNK: Byte = 0x05
const val FILE_COMPLETE: Byte = 0x06
const val FILE_ACK: Byte = 0x07
const val CHUNK_SIZE = 16 * 1024 // 16KB
const val ACK_INTERVAL = 32
}

View File

@@ -0,0 +1,130 @@
package com.omixlab.lckcontrol.p2p.channels
import android.content.Context
import android.util.Log
import com.omixlab.lckcontrol.p2p.webrtc.WebRtcPeerManager
import com.squareup.moshi.Moshi
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileInputStream
import java.nio.ByteBuffer
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FileChannelHandler @Inject constructor(
@ApplicationContext private val context: Context,
private val peerManager: WebRtcPeerManager,
private val moshi: Moshi,
) {
companion object {
private const val TAG = "FileChannelHandler"
}
private val scope = CoroutineScope(Dispatchers.IO)
fun handleData(data: ByteArray) {
if (data.isEmpty()) return
val type = data[0]
when (type) {
FileProtocol.FILE_LIST_REQUEST -> handleFileListRequest()
FileProtocol.FILE_REQUEST -> handleFileRequest(data)
FileProtocol.FILE_ACK -> { /* acknowledged, continue sending */ }
}
}
private fun handleFileListRequest() {
val files = mutableListOf<Map<String, Any?>>()
listFilesIn(context.getExternalFilesDir("clips"), files)
listFilesIn(context.getExternalFilesDir("cortex"), files)
val json = moshi.adapter(Any::class.java).toJson(files)
val jsonBytes = json.toByteArray()
val buffer = ByteBuffer.allocate(1 + jsonBytes.size)
buffer.put(FileProtocol.FILE_LIST_RESPONSE)
buffer.put(jsonBytes)
peerManager.sendFileData(buffer.array())
}
private fun listFilesIn(dir: File?, output: MutableList<Map<String, Any?>>) {
dir?.listFiles()?.forEach { file ->
output.add(mapOf(
"path" to file.absolutePath,
"name" to file.name,
"size" to file.length(),
"isDirectory" to file.isDirectory,
"modifiedAt" to file.lastModified().toString(),
))
}
}
private fun handleFileRequest(data: ByteArray) {
if (data.size < 17) return
val transferId = String(data, 1, 16).trim()
val filePath = String(data, 17, data.size - 17)
scope.launch {
sendFile(transferId, filePath)
}
}
private fun sendFile(transferId: String, filePath: String) {
try {
val file = File(filePath)
if (!file.exists()) {
Log.e(TAG, "File not found: $filePath")
return
}
// Validate path is within allowed directories
val allowedDirs = listOf(
context.getExternalFilesDir("clips")?.absolutePath,
context.getExternalFilesDir("cortex")?.absolutePath,
)
if (allowedDirs.none { it != null && filePath.startsWith(it) }) {
Log.e(TAG, "Path not allowed: $filePath")
return
}
// Send header
val idBytes = transferId.padEnd(16).toByteArray().take(16).toByteArray()
val meta = """{"fileName":"${file.name}","fileSize":${file.length()}}"""
val metaBytes = meta.toByteArray()
val headerBuf = ByteBuffer.allocate(1 + 16 + metaBytes.size)
headerBuf.put(FileProtocol.FILE_HEADER)
headerBuf.put(idBytes)
headerBuf.put(metaBytes)
peerManager.sendFileData(headerBuf.array())
// Send chunks
val input = FileInputStream(file)
val chunkBuf = ByteArray(FileProtocol.CHUNK_SIZE)
var bytesRead: Int
while (input.read(chunkBuf).also { bytesRead = it } > 0) {
val msgBuf = ByteBuffer.allocate(1 + 16 + bytesRead)
msgBuf.put(FileProtocol.FILE_CHUNK)
msgBuf.put(idBytes)
msgBuf.put(chunkBuf, 0, bytesRead)
peerManager.sendFileData(msgBuf.array())
}
input.close()
// Send complete
val completeBuf = ByteBuffer.allocate(1 + 16)
completeBuf.put(FileProtocol.FILE_COMPLETE)
completeBuf.put(idBytes)
peerManager.sendFileData(completeBuf.array())
Log.d(TAG, "File transfer complete: $transferId ($filePath)")
} catch (e: Exception) {
Log.e(TAG, "File transfer error: $transferId", e)
}
}
}

View File

@@ -0,0 +1,72 @@
package com.omixlab.lckcontrol.p2p.discovery
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NsdAdvertiser @Inject constructor(
@ApplicationContext private val context: Context,
private val p2pPreferences: P2pPreferences,
) {
companion object {
private const val TAG = "NsdAdvertiser"
private const val SERVICE_TYPE = "_lckcontrol._tcp."
private const val SERVICE_NAME = "LCKControl"
const val PORT = 8765
}
private val nsdManager: NsdManager =
context.getSystemService(Context.NSD_SERVICE) as NsdManager
private var registrationListener: NsdManager.RegistrationListener? = null
fun startAdvertising(userId: String) {
if (!p2pPreferences.lanDiscoveryEnabled) return
if (registrationListener != null) return
val serviceInfo = NsdServiceInfo().apply {
serviceName = SERVICE_NAME
serviceType = SERVICE_TYPE
port = PORT
setAttribute("userId", userId)
setAttribute("deviceId", p2pPreferences.stableDeviceId)
setAttribute("model", Build.MODEL)
}
registrationListener = object : NsdManager.RegistrationListener {
override fun onServiceRegistered(info: NsdServiceInfo?) {
Log.d(TAG, "NSD registered: ${info?.serviceName}")
}
override fun onRegistrationFailed(info: NsdServiceInfo?, errorCode: Int) {
Log.e(TAG, "NSD registration failed: $errorCode")
registrationListener = null
}
override fun onServiceUnregistered(info: NsdServiceInfo?) {
Log.d(TAG, "NSD unregistered")
}
override fun onUnregistrationFailed(info: NsdServiceInfo?, errorCode: Int) {
Log.e(TAG, "NSD unregistration failed: $errorCode")
}
}
nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener)
}
fun stopAdvertising() {
registrationListener?.let {
try {
nsdManager.unregisterService(it)
} catch (_: Exception) {}
}
registrationListener = null
}
}

View File

@@ -0,0 +1,34 @@
package com.omixlab.lckcontrol.p2p.discovery
import android.content.Context
import android.content.SharedPreferences
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class P2pPreferences @Inject constructor(
@ApplicationContext context: Context,
) {
private val prefs: SharedPreferences =
context.getSharedPreferences("lck_p2p_prefs", Context.MODE_PRIVATE)
var lanDiscoveryEnabled: Boolean
get() = prefs.getBoolean(KEY_LAN_DISCOVERY, true)
set(value) = prefs.edit().putBoolean(KEY_LAN_DISCOVERY, value).apply()
val stableDeviceId: String
get() {
val existing = prefs.getString(KEY_DEVICE_ID, null)
if (existing != null) return existing
val id = UUID.randomUUID().toString()
prefs.edit().putString(KEY_DEVICE_ID, id).apply()
return id
}
companion object {
private const val KEY_LAN_DISCOVERY = "lan_discovery_enabled"
private const val KEY_DEVICE_ID = "stable_device_id"
}
}

View File

@@ -0,0 +1,61 @@
package com.omixlab.lckcontrol.p2p.webrtc
import android.util.Log
import org.webrtc.*
import java.nio.ByteBuffer
import java.util.concurrent.atomic.AtomicBoolean
/**
* Wraps H.264 NAL units from the native encoder into WebRTC VideoTrack.
* This uses the WebRTC encoded frame injection API to pass already-encoded
* H.264 data directly to the peer connection without re-encoding.
*/
class EncodedVideoSource(
private val peerConnectionFactory: PeerConnectionFactory?,
) {
companion object {
private const val TAG = "EncodedVideoSource"
}
private var videoSource: VideoSource? = null
private var videoTrack: VideoTrack? = null
private val isActive = AtomicBoolean(false)
private var frameCount = 0L
fun createVideoTrack(): VideoTrack? {
videoSource = peerConnectionFactory?.createVideoSource(false)
videoTrack = peerConnectionFactory?.createVideoTrack("quest_camera", videoSource)
isActive.set(true)
return videoTrack
}
/**
* Called from the native encoder callback with H.264 NAL units.
* In a production implementation, this would use the WebRTC
* EncodedImage API for zero-transcode passthrough.
*
* For now, this is a placeholder that will be connected to the
* native encoder's frame callback.
*/
fun onEncodedFrame(data: ByteArray, isKeyFrame: Boolean, timestampNs: Long) {
if (!isActive.get()) return
frameCount++
if (frameCount % 300 == 0L) {
Log.d(TAG, "Encoded frames delivered: $frameCount, keyFrame: $isKeyFrame")
}
// In full implementation:
// 1. Wrap data as EncodedImage
// 2. Submit to VideoSource's CapturerObserver
// This requires custom native WebRTC integration
}
fun stop() {
isActive.set(false)
videoTrack?.dispose()
videoSource?.dispose()
videoTrack = null
videoSource = null
}
}

View File

@@ -0,0 +1,254 @@
package com.omixlab.lckcontrol.p2p.webrtc
import android.content.Context
import android.util.Log
import com.omixlab.lckcontrol.R
import com.omixlab.lckcontrol.data.local.TokenStore
import com.omixlab.lckcontrol.p2p.discovery.NsdAdvertiser
import com.omixlab.lckcontrol.p2p.discovery.P2pPreferences
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.Socket
import java.security.KeyStore
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLServerSocket
@JsonClass(generateAdapter = true)
data class PairRequestBody(val token: String)
@JsonClass(generateAdapter = true)
data class PairResponseBody(val nonce: String, val deviceId: String, val deviceName: String)
@JsonClass(generateAdapter = true)
data class AuthPairResponseBody(val code: String, val deviceId: String, val deviceName: String)
@JsonClass(generateAdapter = true)
data class OfferRequestBody(
val sdp: String,
val type: String,
val iceCandidates: List<IceCandidateDto>,
val nonce: String,
)
@JsonClass(generateAdapter = true)
data class OfferResponseBody(
val sdp: String,
val type: String,
val iceCandidates: List<IceCandidateDto>,
)
@JsonClass(generateAdapter = true)
data class IceCandidateDto(
val sdpMid: String,
val sdpMLineIndex: Int,
val sdp: String,
)
@Singleton
class LanSignalingServer @Inject constructor(
@ApplicationContext private val context: Context,
private val tokenStore: TokenStore,
private val p2pPreferences: P2pPreferences,
private val moshi: Moshi,
private val httpClient: OkHttpClient,
) {
companion object {
private const val TAG = "LanSignalingServer"
private const val BASE_URL = "https://lck.omigame.dev"
private const val KEYSTORE_PASSWORD = "lckcontrol"
}
private var serverSocket: SSLServerSocket? = null
private var serverJob: Job? = null
private val validNonces = mutableMapOf<String, Long>() // nonce -> expiry timestamp
var onOfferReceived: ((String, List<IceCandidateDto>, String) -> OfferResponseBody?)? = null
private fun createSslContext(): SSLContext {
val keyStore = KeyStore.getInstance("PKCS12")
context.resources.openRawResource(R.raw.lck_lan).use { stream ->
keyStore.load(stream, KEYSTORE_PASSWORD.toCharArray())
}
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
kmf.init(keyStore, KEYSTORE_PASSWORD.toCharArray())
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(kmf.keyManagers, null, null)
return sslContext
}
fun start(scope: CoroutineScope) {
if (serverJob != null) return
serverJob = scope.launch(Dispatchers.IO) {
try {
val sslContext = createSslContext()
serverSocket = sslContext.serverSocketFactory
.createServerSocket(NsdAdvertiser.PORT) as SSLServerSocket
Log.d(TAG, "LAN TLS server started on port ${NsdAdvertiser.PORT}")
while (isActive) {
val socket = serverSocket?.accept() ?: break
launch { handleConnection(socket) }
}
} catch (e: Exception) {
if (isActive) Log.e(TAG, "Server error", e)
}
}
}
fun stop() {
serverJob?.cancel()
serverJob = null
serverSocket?.close()
serverSocket = null
validNonces.clear()
}
private fun handleConnection(socket: Socket) {
try {
val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
val writer = OutputStreamWriter(socket.getOutputStream())
// Read HTTP request
val requestLine = reader.readLine() ?: return
val headers = mutableMapOf<String, String>()
var line = reader.readLine()
while (line != null && line.isNotEmpty()) {
val colonIdx = line.indexOf(':')
if (colonIdx > 0) {
headers[line.substring(0, colonIdx).trim().lowercase()] =
line.substring(colonIdx + 1).trim()
}
line = reader.readLine()
}
val contentLength = headers["content-length"]?.toIntOrNull() ?: 0
val body = if (contentLength > 0) {
val chars = CharArray(contentLength)
reader.read(chars)
String(chars)
} else ""
val parts = requestLine.split(" ")
val method = parts.getOrNull(0) ?: ""
val path = parts.getOrNull(1) ?: ""
val (status, responseBody) = when {
method == "POST" && path == "/pair" -> handlePair(body)
method == "POST" && path == "/auth-pair" -> handleAuthPair()
method == "POST" && path == "/offer" -> handleOffer(body)
method == "GET" && path == "/status" -> 200 to """{"status":"ok","deviceId":"${p2pPreferences.stableDeviceId}"}"""
else -> 404 to """{"error":"not found"}"""
}
writer.write("HTTP/1.1 $status OK\r\n")
writer.write("Content-Type: application/json\r\n")
writer.write("Content-Length: ${responseBody.length}\r\n")
writer.write("\r\n")
writer.write(responseBody)
writer.flush()
} catch (e: Exception) {
Log.e(TAG, "Connection error", e)
} finally {
socket.close()
}
}
private fun handleAuthPair(): Pair<Int, String> {
return try {
if (!tokenStore.isLoggedIn()) {
return 401 to """{"error":"quest not authenticated"}"""
}
// Use OkHttp (has AuthInterceptor for auto-refresh) to generate pairing code
val request = Request.Builder()
.url("$BASE_URL/auth/pairing/generate")
.post("{}".toRequestBody("application/json".toMediaType()))
.build()
val resp = httpClient.newCall(request).execute()
val respBody = resp.body?.string() ?: ""
if (!resp.isSuccessful) {
Log.e(TAG, "Backend pairing generate failed: ${resp.code} $respBody")
return 502 to """{"error":"failed to generate code"}"""
}
// Parse code from backend response {"code":"123456","expiresAt":"..."}
val codeMatch = Regex(""""code"\s*:\s*"(\d+)"""").find(respBody)
val code = codeMatch?.groupValues?.get(1)
?: return 500 to """{"error":"could not parse code"}"""
val response = AuthPairResponseBody(
code = code,
deviceId = p2pPreferences.stableDeviceId,
deviceName = android.os.Build.MODEL,
)
200 to moshi.adapter(AuthPairResponseBody::class.java).toJson(response)
} catch (e: Exception) {
Log.e(TAG, "Auth-pair error", e)
500 to """{"error":"internal error"}"""
}
}
private fun handlePair(body: String): Pair<Int, String> {
return try {
val request = moshi.adapter(PairRequestBody::class.java).fromJson(body)
?: return 400 to """{"error":"invalid body"}"""
// Validate that the JWT belongs to the same user
// For now, accept any valid-looking JWT
if (request.token.split(".").size != 3) {
return 401 to """{"error":"invalid token"}"""
}
val nonce = UUID.randomUUID().toString()
validNonces[nonce] = System.currentTimeMillis() + 300_000 // 5 min expiry
val response = PairResponseBody(
nonce = nonce,
deviceId = p2pPreferences.stableDeviceId,
deviceName = android.os.Build.MODEL,
)
200 to moshi.adapter(PairResponseBody::class.java).toJson(response)
} catch (e: Exception) {
Log.e(TAG, "Pair error", e)
500 to """{"error":"internal error"}"""
}
}
private fun handleOffer(body: String): Pair<Int, String> {
return try {
val request = moshi.adapter(OfferRequestBody::class.java).fromJson(body)
?: return 400 to """{"error":"invalid body"}"""
// Validate nonce
val expiry = validNonces[request.nonce]
if (expiry == null || System.currentTimeMillis() > expiry) {
return 401 to """{"error":"invalid or expired nonce"}"""
}
validNonces.remove(request.nonce)
val response = onOfferReceived?.invoke(request.sdp, request.iceCandidates, request.nonce)
?: return 500 to """{"error":"peer not ready"}"""
200 to moshi.adapter(OfferResponseBody::class.java).toJson(response)
} catch (e: Exception) {
Log.e(TAG, "Offer error", e)
500 to """{"error":"internal error"}"""
}
}
}

View File

@@ -0,0 +1,116 @@
package com.omixlab.lckcontrol.p2p.webrtc
import android.util.Log
import com.omixlab.lckcontrol.data.local.TokenStore
import com.omixlab.lckcontrol.p2p.discovery.P2pPreferences
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import okhttp3.*
import javax.inject.Inject
import javax.inject.Singleton
@JsonClass(generateAdapter = true)
data class SignalingMessage(
val type: String,
val from: String? = null,
val to: String? = null,
val sdp: String? = null,
val sdpType: String? = null,
val candidate: IceCandidateDto? = null,
val deviceId: String? = null,
val deviceType: String? = null,
val deviceName: String? = null,
)
@Singleton
class RemoteSignalingClient @Inject constructor(
private val okHttpClient: OkHttpClient,
private val moshi: Moshi,
private val tokenStore: TokenStore,
private val p2pPreferences: P2pPreferences,
) {
companion object {
private const val TAG = "RemoteSignaling"
private const val BASE_URL = "wss://lck.omigame.dev/"
}
private var webSocket: WebSocket? = null
var onOfferReceived: ((String, String) -> Unit)? = null // from, sdp
var onIceCandidateReceived: ((String, IceCandidateDto) -> Unit)? = null
fun connect() {
val token = tokenStore.getJwt() ?: return
val request = Request.Builder()
.url("${BASE_URL}signaling/ws?token=$token")
.build()
webSocket = okHttpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(ws: WebSocket, response: Response) {
Log.d(TAG, "Signaling connected, registering device")
send(SignalingMessage(
type = "register_device",
deviceId = p2pPreferences.stableDeviceId,
deviceType = "QUEST",
deviceName = android.os.Build.MODEL,
))
}
override fun onMessage(ws: WebSocket, text: String) {
try {
val msg = moshi.adapter(SignalingMessage::class.java).fromJson(text) ?: return
when (msg.type) {
"offer" -> {
msg.sdp?.let { sdp ->
onOfferReceived?.invoke(msg.from ?: "", sdp)
}
}
"ice_candidate" -> {
msg.candidate?.let { candidate ->
onIceCandidateReceived?.invoke(msg.from ?: "", candidate)
}
}
"registered" -> Log.d(TAG, "Device registered on backend")
}
} catch (e: Exception) {
Log.e(TAG, "Parse error", e)
}
}
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) {
Log.e(TAG, "Signaling failure", t)
}
override fun onClosed(ws: WebSocket, code: Int, reason: String) {
Log.d(TAG, "Signaling closed: $code $reason")
}
})
}
fun sendAnswer(toDeviceId: String, sdp: String) {
send(SignalingMessage(
type = "answer",
to = toDeviceId,
sdp = sdp,
sdpType = "answer",
))
}
fun sendIceCandidate(toDeviceId: String, candidate: IceCandidateDto) {
send(SignalingMessage(
type = "ice_candidate",
to = toDeviceId,
candidate = candidate,
))
}
fun disconnect() {
webSocket?.close(1000, "Disconnect")
webSocket = null
}
private fun send(message: SignalingMessage) {
val json = moshi.adapter(SignalingMessage::class.java).toJson(message)
webSocket?.send(json)
}
}

View File

@@ -0,0 +1,200 @@
package com.omixlab.lckcontrol.p2p.webrtc
import android.content.Context
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import org.webrtc.*
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class WebRtcPeerManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
companion object {
private const val TAG = "WebRtcPeerManager"
}
private var peerConnectionFactory: PeerConnectionFactory? = null
private var peerConnection: PeerConnection? = null
private var controlChannel: DataChannel? = null
private var fileChannel: DataChannel? = null
private var eglBase: EglBase? = null
var onControlMessage: ((String) -> Unit)? = null
var onFileData: ((ByteArray) -> Unit)? = null
fun initialize() {
PeerConnectionFactory.initialize(
PeerConnectionFactory.InitializationOptions.builder(context)
.setEnableInternalTracer(false)
.createInitializationOptions()
)
eglBase = EglBase.create()
peerConnectionFactory = PeerConnectionFactory.builder()
.setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase!!.eglBaseContext, true, true))
.setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBase!!.eglBaseContext))
.createPeerConnectionFactory()
}
fun handleOffer(
offerSdp: String,
remoteCandidates: List<IceCandidateDto>,
): OfferResponseBody? {
val config = PeerConnection.RTCConfiguration(emptyList()).apply {
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
}
var answerSdp: SessionDescription? = null
val localCandidates = mutableListOf<IceCandidate>()
peerConnection = peerConnectionFactory?.createPeerConnection(config, object : PeerConnection.Observer {
override fun onSignalingChange(state: PeerConnection.SignalingState?) {}
override fun onIceConnectionChange(state: PeerConnection.IceConnectionState?) {
Log.d(TAG, "ICE state: $state")
}
override fun onIceConnectionReceivingChange(receiving: Boolean) {}
override fun onIceGatheringChange(state: PeerConnection.IceGatheringState?) {}
override fun onIceCandidate(candidate: IceCandidate?) {
candidate?.let { localCandidates.add(it) }
}
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>?) {}
override fun onAddStream(stream: MediaStream?) {}
override fun onRemoveStream(stream: MediaStream?) {}
override fun onDataChannel(dc: DataChannel?) {
dc ?: return
when (dc.label()) {
"control" -> {
controlChannel = dc
dc.registerObserver(createControlObserver())
}
"files" -> {
fileChannel = dc
dc.registerObserver(createFileObserver())
}
}
}
override fun onRenegotiationNeeded() {}
override fun onAddTrack(receiver: RtpReceiver?, streams: Array<out MediaStream>?) {}
})
// Set remote offer
val offer = SessionDescription(SessionDescription.Type.OFFER, offerSdp)
val setRemoteLatch = java.util.concurrent.CountDownLatch(1)
peerConnection?.setRemoteDescription(object : SdpObserver {
override fun onCreateSuccess(p0: SessionDescription?) {}
override fun onSetSuccess() { setRemoteLatch.countDown() }
override fun onCreateFailure(p0: String?) { setRemoteLatch.countDown() }
override fun onSetFailure(p0: String?) { setRemoteLatch.countDown() }
}, offer)
if (!setRemoteLatch.await(5, java.util.concurrent.TimeUnit.SECONDS)) {
return null
}
// Add remote ICE candidates
for (candidate in remoteCandidates) {
peerConnection?.addIceCandidate(
IceCandidate(candidate.sdpMid, candidate.sdpMLineIndex, candidate.sdp)
)
}
// Create answer
val answerLatch = java.util.concurrent.CountDownLatch(1)
peerConnection?.createAnswer(object : SdpObserver {
override fun onCreateSuccess(sdp: SessionDescription?) {
sdp?.let { answer ->
answerSdp = answer
peerConnection?.setLocalDescription(object : SdpObserver {
override fun onCreateSuccess(p0: SessionDescription?) {}
override fun onSetSuccess() { answerLatch.countDown() }
override fun onCreateFailure(p0: String?) { answerLatch.countDown() }
override fun onSetFailure(p0: String?) { answerLatch.countDown() }
}, answer)
}
}
override fun onSetSuccess() {}
override fun onCreateFailure(error: String?) {
Log.e(TAG, "Create answer failed: $error")
answerLatch.countDown()
}
override fun onSetFailure(error: String?) { answerLatch.countDown() }
}, MediaConstraints())
if (!answerLatch.await(5, java.util.concurrent.TimeUnit.SECONDS) || answerSdp == null) {
return null
}
// Wait briefly for ICE candidates
Thread.sleep(500)
return OfferResponseBody(
sdp = answerSdp!!.description,
type = "answer",
iceCandidates = localCandidates.map {
IceCandidateDto(it.sdpMid, it.sdpMLineIndex, it.sdp)
},
)
}
fun sendControlMessage(json: String) {
controlChannel?.send(DataChannel.Buffer(
java.nio.ByteBuffer.wrap(json.toByteArray()),
false,
))
}
fun sendFileData(data: ByteArray) {
fileChannel?.send(DataChannel.Buffer(
java.nio.ByteBuffer.wrap(data),
true,
))
}
fun addVideoTrack(videoTrack: VideoTrack) {
peerConnection?.addTrack(videoTrack)
}
fun disconnect() {
controlChannel?.close()
fileChannel?.close()
peerConnection?.close()
peerConnection = null
controlChannel = null
fileChannel = null
}
fun release() {
disconnect()
peerConnectionFactory?.dispose()
peerConnectionFactory = null
eglBase?.release()
eglBase = null
}
private fun createControlObserver() = object : DataChannel.Observer {
override fun onBufferedAmountChange(previous: Long) {}
override fun onStateChange() {}
override fun onMessage(buffer: DataChannel.Buffer?) {
buffer?.let {
val bytes = ByteArray(it.data.remaining())
it.data.get(bytes)
onControlMessage?.invoke(String(bytes))
}
}
}
private fun createFileObserver() = object : DataChannel.Observer {
override fun onBufferedAmountChange(previous: Long) {}
override fun onStateChange() {}
override fun onMessage(buffer: DataChannel.Buffer?) {
buffer?.let {
val bytes = ByteArray(it.data.remaining())
it.data.get(bytes)
onFileData?.invoke(bytes)
}
}
}
}

View File

@@ -0,0 +1,13 @@
package com.omixlab.lckcontrol.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
context.startForegroundService(Intent(context, LckControlService::class.java))
}
}
}

View File

@@ -13,12 +13,17 @@ import com.meta.horizon.platform.ovr.Core
import com.meta.horizon.platform.ovr.requests.Request
import com.meta.horizon.platform.ovr.requests.Users
import com.omixlab.lckcontrol.R
import com.omixlab.lckcontrol.chat.ChatNotificationManager
import com.omixlab.lckcontrol.data.local.AppPreferences
import com.omixlab.lckcontrol.data.local.TokenStore
import com.omixlab.lckcontrol.data.remote.LckApiService
import com.omixlab.lckcontrol.data.remote.MetaCallbackRequest
import com.omixlab.lckcontrol.data.remote.RefreshRequest
import com.omixlab.lckcontrol.data.repository.AccountRepository
import com.omixlab.lckcontrol.data.repository.ChatRepository
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
import com.omixlab.lckcontrol.p2p.PeerSessionManager
import com.omixlab.lckcontrol.p2p.discovery.P2pPreferences
import com.omixlab.lckcontrol.shared.ConnectedClientInfo
import com.omixlab.lckcontrol.shared.ILckControlCallback
import com.omixlab.lckcontrol.shared.ILckControlService
@@ -26,6 +31,9 @@ import com.omixlab.lckcontrol.shared.LinkedAccount
import com.omixlab.lckcontrol.shared.StreamDestination
import com.omixlab.lckcontrol.shared.StreamPlan
import com.omixlab.lckcontrol.shared.StreamPlanConfig
import com.omixlab.lckcontrol.shared.StreamingConfig
import com.omixlab.lckcontrol.streaming.StreamingManager
import com.omixlab.lckcontrol.streaming.StreamingState
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -48,15 +56,23 @@ class LckControlService : Service() {
private const val NOTIFICATION_ID = 1
private const val QUEST_APP_ID = "25653777174321448"
private const val TOKEN_REFRESH_INTERVAL_MS = 60_000L
private const val ACTION_BIND_STREAMING = "com.omixlab.lckcontrol.BIND_STREAMING"
}
@Inject lateinit var appPreferences: AppPreferences
@Inject lateinit var accountRepository: AccountRepository
@Inject lateinit var streamPlanRepository: StreamPlanRepository
@Inject lateinit var tokenStore: TokenStore
@Inject lateinit var apiService: LckApiService
@Inject lateinit var streamingManager: StreamingManager
@Inject lateinit var chatRepository: ChatRepository
@Inject lateinit var chatNotificationManager: ChatNotificationManager
@Inject lateinit var peerSessionManager: PeerSessionManager
@Inject lateinit var p2pPreferences: P2pPreferences
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val clientTracker = ClientTracker()
private var streamingServiceImpl: LckStreamingServiceImpl? = null
private val callbacks = object : RemoteCallbackList<ILckControlCallback>() {
override fun onCallbackDied(callback: ILckControlCallback, cookie: Any?) {
val uid = cookie as? Int ?: return
@@ -95,13 +111,22 @@ class LckControlService : Service() {
// ── Stream plans ────────────────────────────────────
override fun createStreamPlan(config: StreamPlanConfig): StreamPlan = runBlocking {
val plan = streamPlanRepository.createPlan(config.name, config.destinations)
val plan = streamPlanRepository.createPlan(
name = config.name,
destinations = config.destinations,
executionMode = config.executionMode,
gameId = config.gameId,
)
broadcastPlansChanged()
plan
}
override fun createDefaultPlan(clientName: String): StreamPlan = runBlocking {
val accounts = accountRepository.getAccounts()
val accounts = accountRepository.getAccounts().filter { it.isEnabled }
val gameId = clientTracker.getAll()
.find { it.clientName == clientName }?.packageName ?: ""
val execMode = appPreferences.getDefaultExecutionMode()
Log.d(TAG, "createDefaultPlan: clientName=$clientName, executionMode=$execMode, accounts=${accounts.size}")
val destinations = accounts.map { account ->
StreamDestination(
service = account.serviceId,
@@ -110,7 +135,13 @@ class LckControlService : Service() {
privacyStatus = "unlisted",
)
}
val plan = streamPlanRepository.createPlan("$clientName Stream", destinations)
val plan = streamPlanRepository.createPlan(
name = "$clientName Stream",
destinations = destinations,
executionMode = execMode,
gameId = gameId,
)
Log.d(TAG, "createDefaultPlan: created plan ${plan.planId} with executionMode=${plan.executionMode}")
broadcastPlansChanged()
plan
}
@@ -132,14 +163,40 @@ class LckControlService : Service() {
override fun startStreamPlan(planId: String): Boolean = runBlocking {
val plan = streamPlanRepository.getPlan(planId) ?: return@runBlocking false
if (plan.status == "LIVE") return@runBlocking true
if (plan.status == "LIVE") {
// Plan already LIVE — ensure streaming engine is running for APP_STREAMING
if (plan.executionMode == "APP_STREAMING" && !streamingManager.isStreaming()) {
Log.d(TAG, "startStreamPlan: plan already LIVE but engine not running, starting engine")
streamingManager.startStreaming(
plan = plan,
config = StreamingConfig(),
width = 1920,
height = 1080,
)
}
return@runBlocking true
}
if (plan.status != "READY") return@runBlocking false
try {
streamPlanRepository.startPlan(planId)
val updated = streamPlanRepository.getPlan(planId)
// If APP_STREAMING mode, start the streaming engine
if (updated?.executionMode == "APP_STREAMING") {
streamingManager.startStreaming(
plan = updated,
config = StreamingConfig(),
width = 1920,
height = 1080,
)
}
if (updated != null) broadcastPlanUpdated(updated)
true
} catch (_: Exception) { false }
} catch (e: Throwable) {
Log.e(TAG, "startStreamPlan failed", e)
false
}
}
override fun endStreamPlan(planId: String): Boolean = runBlocking {
@@ -147,6 +204,11 @@ class LckControlService : Service() {
if (plan.status == "ENDED") return@runBlocking true
if (plan.status != "LIVE" && plan.status != "READY") return@runBlocking false
try {
// Stop streaming engine if running
if (plan.executionMode == "APP_STREAMING") {
streamingManager.stopStreaming()
}
streamPlanRepository.endPlan(planId)
val updated = streamPlanRepository.getPlan(planId)
if (updated != null) broadcastPlanUpdated(updated)
@@ -222,11 +284,85 @@ class LckControlService : Service() {
}
}
}
// Forward streaming state changes to AIDL callbacks
serviceScope.launch {
streamingManager.state.collect { state ->
streamingServiceImpl?.broadcastStateChanged(state)
}
}
serviceScope.launch {
streamingManager.stats.collect { stats ->
streamingServiceImpl?.broadcastStats(
stats.videoBitrate, stats.audioBitrate, stats.fps, stats.droppedFrames,
)
}
}
// Forward buffer release events to AIDL callbacks
streamingManager.onBufferReleased = { bufferIndex ->
streamingServiceImpl?.broadcastBufferReleased(bufferIndex)
}
// Initialize chat notification manager and connect WebSocket
chatNotificationManager.init()
chatRepository.connect()
// Start P2P session once authenticated — poll until login completes
serviceScope.launch {
// Wait for auto-login to complete (up to 30s)
var attempts = 0
while (!tokenStore.isLoggedIn() && attempts < 60) {
delay(500)
attempts++
}
if (p2pPreferences.lanDiscoveryEnabled && tokenStore.isLoggedIn()) {
val userId = extractJwtSub(tokenStore.getJwt() ?: "") ?: return@launch
peerSessionManager.start(userId, serviceScope)
Log.d(TAG, "P2P session started for user $userId")
} else {
Log.w(TAG, "P2P session not started: logged_in=${tokenStore.isLoggedIn()}, lan_enabled=${p2pPreferences.lanDiscoveryEnabled}")
}
}
// Auto-subscribe/unsubscribe chat when plans go LIVE/ENDED
serviceScope.launch {
streamPlanRepository.observePlans().collect { plans ->
Log.d(TAG, "observePlans emitted ${plans.size} plans")
for (plan in plans) {
val destServices = plan.destinations.map { it.service }
Log.d(TAG, "Plan ${plan.planId} status=${plan.status} destinations=$destServices")
val hasChat = plan.destinations.any {
it.service == "YOUTUBE" || it.service == "TWITCH"
}
if (plan.status == "LIVE" && hasChat) {
Log.d(TAG, "Subscribing chat for plan ${plan.planId}")
chatRepository.subscribe(plan.planId)
} else if (plan.status == "ENDED") {
chatRepository.unsubscribe(plan.planId)
}
}
}
}
}
override fun onBind(intent: Intent?): IBinder = binder
override fun onBind(intent: Intent?): IBinder? {
return when (intent?.action) {
ACTION_BIND_STREAMING -> {
if (streamingServiceImpl == null) {
streamingServiceImpl = LckStreamingServiceImpl(streamingManager)
}
streamingServiceImpl!!.asBinder()
}
else -> binder
}
}
override fun onDestroy() {
peerSessionManager.release()
chatRepository.disconnect()
streamingManager.stopStreaming()
streamingServiceImpl?.kill()
serviceScope.cancel()
callbacks.kill()
super.onDestroy()
@@ -234,15 +370,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) {
@@ -252,6 +401,7 @@ class LckControlService : Service() {
}
// Full Quest SDK login
Log.d(TAG, "Starting Quest SDK login (previous userId=$oldSub)")
doQuestLogin()
}
@@ -290,8 +440,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)
}

View File

@@ -0,0 +1,138 @@
package com.omixlab.lckcontrol.service
import android.hardware.HardwareBuffer
import android.os.ParcelFileDescriptor
import android.os.RemoteCallbackList
import android.util.Log
import com.omixlab.lckcontrol.shared.ILckStreamingCallback
import com.omixlab.lckcontrol.shared.ILckStreamingService
import com.omixlab.lckcontrol.streaming.StreamingManager
import com.omixlab.lckcontrol.streaming.StreamingState
/**
* AIDL implementation for ILckStreamingService.
* Bridges AIDL IPC calls to the StreamingManager.
* Frame submission methods are one-way for non-blocking game render thread.
*/
class LckStreamingServiceImpl(
private val streamingManager: StreamingManager,
) : ILckStreamingService.Stub() {
companion object {
private const val TAG = "LckStreamingServiceImpl"
}
private val callbacks = RemoteCallbackList<ILckStreamingCallback>()
init {
// Forward state changes to AIDL callbacks
// Note: state observation requires coroutine scope — delegated to LckControlService
}
override fun registerTexturePool(
buffers: Array<HardwareBuffer>,
width: Int,
height: Int,
format: Int,
) {
Log.d(TAG, "registerTexturePool: ${buffers.size} buffers, ${width}x$height")
streamingManager.registerTexturePool(buffers, width, height, format)
}
override fun unregisterTexturePool() {
Log.d(TAG, "unregisterTexturePool")
streamingManager.unregisterTexturePool()
}
override fun submitVideoFrame(
bufferIndex: Int,
timestampNs: Long,
gpuFence: ParcelFileDescriptor?,
) {
val fenceFd = gpuFence?.detachFd() ?: -1
streamingManager.submitVideoFrame(bufferIndex, timestampNs, fenceFd)
}
override fun submitAudioFrame(
pcmData: ByteArray,
timestampNs: Long,
sampleRate: Int,
channels: Int,
bitsPerSample: Int,
) {
streamingManager.submitAudioFrame(pcmData, timestampNs)
}
override fun isStreaming(): Boolean {
return streamingManager.isStreaming()
}
override fun registerStreamingCallback(callback: ILckStreamingCallback) {
callbacks.register(callback)
}
override fun unregisterStreamingCallback(callback: ILckStreamingCallback) {
callbacks.unregister(callback)
}
// ── Broadcast helpers (called from LckControlService coroutine scope) ──
fun broadcastStateChanged(state: StreamingState) {
val stateStr = state.name
val count = callbacks.beginBroadcast()
try {
for (i in 0 until count) {
try {
callbacks.getBroadcastItem(i).onStreamingStateChanged(stateStr)
} catch (_: Exception) {}
}
} finally {
callbacks.finishBroadcast()
}
}
fun broadcastStats(videoBitrate: Long, audioBitrate: Long, fps: Int, droppedFrames: Int) {
val count = callbacks.beginBroadcast()
try {
for (i in 0 until count) {
try {
callbacks.getBroadcastItem(i).onStreamingStats(
videoBitrate, audioBitrate, fps, droppedFrames,
)
} catch (_: Exception) {}
}
} finally {
callbacks.finishBroadcast()
}
}
fun broadcastError(code: Int, message: String) {
val count = callbacks.beginBroadcast()
try {
for (i in 0 until count) {
try {
callbacks.getBroadcastItem(i).onStreamingError(code, message)
} catch (_: Exception) {}
}
} finally {
callbacks.finishBroadcast()
}
}
fun broadcastBufferReleased(bufferIndex: Int) {
val count = callbacks.beginBroadcast()
try {
for (i in 0 until count) {
try {
callbacks.getBroadcastItem(i).onBufferReleased(bufferIndex)
} catch (_: Exception) {}
}
} finally {
callbacks.finishBroadcast()
}
}
fun kill() {
callbacks.kill()
}
}

View File

@@ -0,0 +1,233 @@
package com.omixlab.lckcontrol.streaming
import android.hardware.HardwareBuffer
import android.util.Log
import android.view.Surface
/**
* Thin JNI wrapper around the C++ StreamingEngine.
* All encoding, muxing, and RTMP streaming happens in native code (zero-copy pipeline).
*/
class NativeStreamingEngine {
companion object {
private const val TAG = "NativeStreamingEngine"
init {
System.loadLibrary("lck_streaming")
}
}
private var nativePtr: Long = 0
var onStats: ((StreamingStats) -> Unit)? = null
var onError: ((Int, String) -> Unit)? = null
var onBufferReleased: ((Int) -> Unit)? = null
var onClipReady: ((String) -> Unit)? = null
var onCortexSegment: ((segPath: String, keyframeData: ByteArray) -> Unit)? = null
var onEncodedVideoFrame: ((data: ByteArray, isKeyFrame: Boolean, timestampUs: Long) -> Unit)? = null
fun create(
width: Int,
height: Int,
videoBitrate: Int,
audioBitrate: Int,
sampleRate: Int,
channels: Int,
keyframeInterval: Int,
) {
if (nativePtr != 0L) {
Log.w(TAG, "Engine already created, destroying first")
destroy()
}
nativePtr = nativeCreate(width, height, videoBitrate, audioBitrate,
sampleRate, channels, keyframeInterval)
}
fun addDestination(rtmpUrl: String): Int {
check(nativePtr != 0L) { "Engine not created" }
return nativeAddDestination(nativePtr, rtmpUrl)
}
fun start(): Boolean {
check(nativePtr != 0L) { "Engine not created" }
return nativeStart(nativePtr)
}
fun submitVideoFrame(hardwareBuffer: HardwareBuffer, timestampNs: Long, fenceFd: Int, bufferIndex: Int) {
if (nativePtr == 0L) return
nativeSubmitVideoFrame(nativePtr, hardwareBuffer, timestampNs, fenceFd, bufferIndex)
}
fun submitAudioFrame(pcmData: ByteArray, timestampNs: Long) {
if (nativePtr == 0L) return
nativeSubmitAudioFrame(nativePtr, pcmData, timestampNs)
}
fun stop() {
if (nativePtr == 0L) return
nativeStop(nativePtr)
}
fun destroy() {
if (nativePtr != 0L) {
nativeDestroy(nativePtr)
nativePtr = 0
}
}
fun isRunning(): Boolean {
if (nativePtr == 0L) return false
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)
}
// Clip recording
fun enableClipRecording(width: Int, height: Int) {
if (nativePtr == 0L) return
nativeEnableClipRecording(nativePtr, width, height)
}
fun flushClip(outputDir: String): Boolean {
if (nativePtr == 0L) return false
return nativeFlushClip(nativePtr, outputDir)
}
fun disableClipRecording() {
if (nativePtr == 0L) return
nativeDisableClipRecording(nativePtr)
}
// Cortex recording
fun enableCortexRecording(sessionDir: String, maxMinutes: Int) {
if (nativePtr == 0L) return
nativeEnableCortexRecording(nativePtr, sessionDir, maxMinutes)
}
fun disableCortexRecording() {
if (nativePtr == 0L) return
nativeDisableCortexRecording(nativePtr)
}
// Called from native code (JNI callbacks)
@Suppress("unused")
private fun onNativeStats(videoBitrate: Long, audioBitrate: Long, fps: Int, droppedFrames: Int) {
onStats?.invoke(StreamingStats(videoBitrate, audioBitrate, fps, droppedFrames))
}
@Suppress("unused")
private fun onNativeError(code: Int, message: String) {
Log.e(TAG, "Native error $code: $message")
onError?.invoke(code, message)
}
@Suppress("unused")
private fun onNativeBufferReleased(bufferIndex: Int) {
onBufferReleased?.invoke(bufferIndex)
}
@Suppress("unused")
private fun onNativeClipReady(path: String) {
Log.i(TAG, "Clip ready: $path")
onClipReady?.invoke(path)
}
@Suppress("unused")
private fun onNativeCortexSegment(segPath: String, keyframeData: ByteArray) {
Log.i(TAG, "Cortex segment: $segPath (${keyframeData.size} bytes keyframe)")
onCortexSegment?.invoke(segPath, keyframeData)
}
@Suppress("unused")
private fun onNativeEncodedFrame(data: ByteArray, isKeyFrame: Boolean, timestampUs: Long) {
onEncodedVideoFrame?.invoke(data, isKeyFrame, timestampUs)
}
// Native methods
private external fun nativeCreate(
width: Int, height: Int,
videoBitrate: Int, audioBitrate: Int,
sampleRate: Int, channels: Int,
keyframeInterval: Int,
): Long
private external fun nativeAddDestination(ptr: Long, rtmpUrl: String): Int
private external fun nativeStart(ptr: Long): Boolean
private external fun nativeSubmitVideoFrame(ptr: Long, hardwareBuffer: HardwareBuffer, timestampNs: Long, fenceFd: Int, bufferIndex: Int)
private external fun nativeSubmitAudioFrame(ptr: Long, pcmData: ByteArray, timestampNs: Long)
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)
// Clip recording
private external fun nativeEnableClipRecording(ptr: Long, width: Int, height: Int)
private external fun nativeFlushClip(ptr: Long, outputDir: String): Boolean
private external fun nativeDisableClipRecording(ptr: Long)
// Cortex recording
private external fun nativeEnableCortexRecording(ptr: Long, sessionDir: String, maxMinutes: Int)
private external fun nativeDisableCortexRecording(ptr: Long)
}

View File

@@ -0,0 +1,327 @@
package com.omixlab.lckcontrol.streaming
import android.content.Context
import android.graphics.Bitmap
import android.hardware.HardwareBuffer
import android.util.Log
import android.view.Surface
import com.omixlab.lckcontrol.cortex.CortexManager
import com.omixlab.lckcontrol.data.remote.LckApiService
import com.omixlab.lckcontrol.shared.StreamPlan
import com.omixlab.lckcontrol.shared.StreamingConfig
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
import java.nio.ByteBuffer
import javax.inject.Inject
import javax.inject.Singleton
enum class StreamingState {
IDLE, STARTING, LIVE, STOPPING, ERROR
}
/**
* High-level streaming lifecycle manager.
* Bridges stream plan configuration to the native streaming engine.
* Stream keys and RTMP URLs stay within the app process — never exposed via AIDL.
*/
@Singleton
class StreamingManager @Inject constructor(
@ApplicationContext private val context: Context,
private val apiService: LckApiService,
private val cortexManager: CortexManager,
) {
companion object {
private const val TAG = "StreamingManager"
private const val CLIP_INITIAL_DELAY_MS = 10_000L
private const val CLIP_INTERVAL_MS = 120_000L
}
private var engine: NativeStreamingEngine? = null
private var texturePoolBuffers: Array<HardwareBuffer>? = null
private var texturePoolWidth: Int = 0
private var texturePoolHeight: Int = 0
private val _state = MutableStateFlow(StreamingState.IDLE)
val state: StateFlow<StreamingState> = _state.asStateFlow()
private val _stats = MutableStateFlow(StreamingStats())
val stats: StateFlow<StreamingStats> = _stats.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
private var clipTimer: java.util.Timer? = null
private var currentPlanId: String? = null
private val clipScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/**
* Start streaming for a plan with APP_STREAMING execution mode.
* RTMP URLs are constructed internally from the plan's destinations.
*/
fun startStreaming(plan: StreamPlan, config: StreamingConfig, width: Int, height: Int) {
if (_state.value != StreamingState.IDLE) {
Log.w(TAG, "Cannot start streaming, current state: ${_state.value}")
return
}
val destinations = plan.destinations.filter {
it.rtmpUrl.isNotBlank() && it.streamKey.isNotBlank()
}
if (destinations.isEmpty()) {
_error.value = "No destinations with RTMP credentials"
_state.value = StreamingState.ERROR
return
}
// Use texture pool dimensions if available, otherwise use caller-provided defaults
val actualWidth = if (texturePoolWidth > 0) texturePoolWidth else width
val actualHeight = if (texturePoolHeight > 0) texturePoolHeight else height
Log.d(TAG, "Starting streaming at ${actualWidth}x${actualHeight} (pool=${texturePoolWidth}x${texturePoolHeight}, requested=${width}x${height})")
_state.value = StreamingState.STARTING
_error.value = null
try {
val eng = NativeStreamingEngine()
eng.create(
width = actualWidth,
height = actualHeight,
videoBitrate = config.videoBitrate,
audioBitrate = config.audioBitrate,
sampleRate = config.audioSampleRate,
channels = config.audioChannels,
keyframeInterval = config.keyFrameInterval,
)
// Add RTMP destinations — stream keys stay in-process
for (dest in destinations) {
val fullUrl = "${dest.rtmpUrl}/${dest.streamKey}"
eng.addDestination(fullUrl)
Log.d(TAG, "Added destination: ${dest.service}")
}
eng.onStats = { stats ->
_stats.value = stats
}
eng.onError = { code, message ->
Log.e(TAG, "Streaming error $code: $message")
_error.value = message
_state.value = StreamingState.ERROR
}
eng.onBufferReleased = { index ->
onBufferReleased?.invoke(index)
}
if (eng.start()) {
engine = eng
_state.value = StreamingState.LIVE
Log.i(TAG, "Streaming started with ${destinations.size} destinations")
// Enable cortex recording on the streaming engine
cortexManager.onStreamingStarting(eng)
// Start clip recording
currentPlanId = plan.planId
val clipsDir = File(context.cacheDir, "clips").apply { mkdirs() }
eng.enableClipRecording(actualWidth, actualHeight)
eng.onClipReady = { path ->
clipScope.launch { uploadPreviewClip(path) }
}
clipTimer = java.util.Timer().apply {
schedule(object : java.util.TimerTask() {
override fun run() {
engine?.flushClip(clipsDir.absolutePath)
}
}, CLIP_INITIAL_DELAY_MS, CLIP_INTERVAL_MS)
}
} else {
eng.destroy()
_error.value = "Failed to start streaming engine"
_state.value = StreamingState.ERROR
}
} catch (e: Throwable) {
Log.e(TAG, "Failed to start streaming", e)
_error.value = e.message ?: "Unknown error"
_state.value = StreamingState.ERROR
}
}
/**
* Register texture pool buffers from the game.
* Buffers are stored for reference — the native engine receives individual
* buffers via submitVideoFrame.
*/
fun registerTexturePool(buffers: Array<HardwareBuffer>, width: Int, height: Int, format: Int) {
texturePoolBuffers = buffers
texturePoolWidth = width
texturePoolHeight = height
Log.d(TAG, "Texture pool registered: ${buffers.size} buffers, ${width}x${height}")
cortexManager.onBufferReleased = { idx -> onBufferReleased?.invoke(idx) }
cortexManager.onTexturePoolRegistered(width, height)
}
fun unregisterTexturePool() {
cortexManager.onTexturePoolUnregistered()
texturePoolBuffers = null
texturePoolWidth = 0
texturePoolHeight = 0
Log.d(TAG, "Texture pool unregistered")
}
private var videoFrameCount = 0
/** Callback when a buffer is released after processing. */
var onBufferReleased: ((Int) -> Unit)? = null
/** Forward a video frame from the game to the native engine. */
fun submitVideoFrame(bufferIndex: Int, timestampNs: Long, fenceFd: Int) {
val buffers = texturePoolBuffers
if (buffers == null) {
if (videoFrameCount++ % 30 == 0) Log.w(TAG, "submitVideoFrame: no texture pool")
return
}
if (bufferIndex < 0 || bufferIndex >= buffers.size) {
if (videoFrameCount++ % 30 == 0) Log.w(TAG, "submitVideoFrame: index $bufferIndex out of range [0,${buffers.size})")
return
}
val eng = engine
if (eng != null) {
eng.submitVideoFrame(buffers[bufferIndex], timestampNs, fenceFd, bufferIndex)
} else if (cortexManager.hasCortexEngine) {
cortexManager.submitVideoFrame(buffers[bufferIndex], timestampNs, fenceFd, bufferIndex)
} else {
onBufferReleased?.invoke(bufferIndex)
}
if (++videoFrameCount % 30 == 0) {
Log.d(TAG, "submitVideoFrame: forwarded frame #$videoFrameCount idx=$bufferIndex")
}
}
/** Forward audio PCM from the game to the native engine. */
fun submitAudioFrame(pcmData: ByteArray, timestampNs: Long) {
val eng = engine
if (eng != null) {
eng.submitAudioFrame(pcmData, timestampNs)
} else if (cortexManager.hasCortexEngine) {
cortexManager.submitAudioFrame(pcmData, timestampNs)
}
}
/** Stop streaming and release all resources. */
fun stopStreaming() {
Log.w(TAG, "stopStreaming() called from state=${_state.value}", Exception("Caller trace"))
if (_state.value != StreamingState.LIVE && _state.value != StreamingState.ERROR) {
return
}
_state.value = StreamingState.STOPPING
// Stop clip recording
clipTimer?.cancel()
clipTimer = null
engine?.disableClipRecording()
currentPlanId = null
engine?.let { eng ->
eng.stop()
eng.destroy()
}
engine = null
// Restart cortex-only engine if game is still connected
cortexManager.onStreamingStopped()
_state.value = StreamingState.IDLE
_stats.value = StreamingStats()
Log.i(TAG, "Streaming stopped")
}
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 suspend fun uploadPreviewClip(clipPath: String) {
try {
val file = File(clipPath)
if (!file.exists() || file.length() == 0L) return
val planId = currentPlanId ?: return
val body = file.asRequestBody("video/mp4".toMediaType())
val part = MultipartBody.Part.createFormData("preview", file.name, body)
apiService.uploadPreview(planId, part)
Log.i(TAG, "Preview clip uploaded for plan $planId")
file.delete()
} catch (e: Exception) {
Log.e(TAG, "Failed to upload preview clip", e)
}
}
private fun bitmapToRgba(bitmap: Bitmap): ByteArray {
val argbBitmap = if (bitmap.config != Bitmap.Config.ARGB_8888) {
bitmap.copy(Bitmap.Config.ARGB_8888, false)
} else {
bitmap
}
val buffer = ByteBuffer.allocate(argbBitmap.byteCount)
argbBitmap.copyPixelsToBuffer(buffer)
if (argbBitmap !== bitmap) argbBitmap.recycle()
return buffer.array()
}
}

View File

@@ -0,0 +1,8 @@
package com.omixlab.lckcontrol.streaming
data class StreamingStats(
val videoBitrate: Long = 0,
val audioBitrate: Long = 0,
val fps: Int = 0,
val droppedFrames: Int = 0,
)

View File

@@ -9,21 +9,29 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
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.Button
import androidx.compose.material3.CircularProgressIndicator
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
@@ -32,7 +40,13 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -43,6 +57,14 @@ 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 pairingCode by viewModel.pairingCode.collectAsStateWithLifecycle()
val pairingExpiresAt by viewModel.pairingExpiresAt.collectAsStateWithLifecycle()
val pairingLoading by viewModel.pairingLoading.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
@@ -53,6 +75,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") })
@@ -66,6 +136,18 @@ fun AccountsScreen(
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Spacer(Modifier.height(8.dp))
Text("Portal Pairing", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
PairingCodeCard(
code = pairingCode,
expiresAt = pairingExpiresAt,
loading = pairingLoading,
onGenerate = viewModel::generatePairingCode,
)
}
item { Spacer(Modifier.height(8.dp)) }
items(accounts, key = { it.id }) { account ->
@@ -78,8 +160,16 @@ 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,
onCheckedChange = { viewModel.toggleAccountEnabled(account.id, it) },
)
IconButton(onClick = { viewModel.unlinkAccount(account.id) }) {
Icon(Icons.Default.LinkOff, contentDescription = "Unlink")
}
@@ -108,7 +198,108 @@ 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)) }
}
}
}
@Composable
private fun PairingCodeCard(
code: String?,
expiresAt: Long?,
loading: Boolean,
onGenerate: () -> Unit,
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (code != null && expiresAt != null) {
val remainingSeconds = ((expiresAt - System.currentTimeMillis()) / 1000).coerceAtLeast(0)
// Countdown ticker
val tickState = remember { MutableStateFlow(remainingSeconds) }
val currentRemaining by tickState.collectAsStateWithLifecycle()
LaunchedEffect(expiresAt) {
while (true) {
val r = ((expiresAt - System.currentTimeMillis()) / 1000).coerceAtLeast(0)
tickState.value = r
if (r <= 0) break
delay(1_000)
}
}
if (currentRemaining > 0) {
Text(
"Enter this code on the portal:",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
Text(
text = code.chunked(3).joinToString(" "),
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
letterSpacing = androidx.compose.ui.unit.TextUnit(4f, androidx.compose.ui.unit.TextUnitType.Sp),
),
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
val minutes = currentRemaining / 60
val seconds = currentRemaining % 60
Text(
text = "Expires in %d:%02d".format(minutes, seconds),
style = MaterialTheme.typography.bodySmall,
color = if (currentRemaining < 60) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
OutlinedButton(onClick = onGenerate, enabled = !loading) {
Text("Generate New Code")
}
} else {
Text(
"Code expired",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(8.dp))
Button(onClick = onGenerate, enabled = !loading) {
Text("Generate New Code")
}
}
} else {
Text(
"Link your portal account by generating a pairing code",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Button(onClick = onGenerate, enabled = !loading) {
if (loading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(8.dp))
}
Text("Link Portal")
}
}
}
}
}

View File

@@ -5,9 +5,12 @@ import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.omixlab.lckcontrol.data.remote.LckApiService
import com.omixlab.lckcontrol.data.repository.AccountRepository
import com.omixlab.lckcontrol.shared.LinkedAccount
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -29,6 +32,7 @@ val ALL_PROVIDERS = listOf(
@HiltViewModel
class AccountsViewModel @Inject constructor(
private val accountRepository: AccountRepository,
private val apiService: LckApiService,
) : ViewModel() {
val accounts: StateFlow<List<LinkedAccount>> = accountRepository.observeAccounts()
@@ -37,6 +41,34 @@ class AccountsViewModel @Inject constructor(
private val _linkError = MutableStateFlow<String?>(null)
val linkError: StateFlow<String?> = _linkError.asStateFlow()
// Custom RTMP dialog state
private val _showCustomRtmpDialog = MutableStateFlow(false)
val showCustomRtmpDialog: StateFlow<Boolean> = _showCustomRtmpDialog.asStateFlow()
private val _customRtmpName = MutableStateFlow("")
val customRtmpName: StateFlow<String> = _customRtmpName.asStateFlow()
private val _customRtmpUrl = MutableStateFlow("")
val customRtmpUrl: StateFlow<String> = _customRtmpUrl.asStateFlow()
private val _customRtmpKey = MutableStateFlow("")
val customRtmpKey: StateFlow<String> = _customRtmpKey.asStateFlow()
private val _isCreatingCustomRtmp = MutableStateFlow(false)
val isCreatingCustomRtmp: StateFlow<Boolean> = _isCreatingCustomRtmp.asStateFlow()
// Pairing code state
private val _pairingCode = MutableStateFlow<String?>(null)
val pairingCode: StateFlow<String?> = _pairingCode.asStateFlow()
private val _pairingExpiresAt = MutableStateFlow<Long?>(null)
val pairingExpiresAt: StateFlow<Long?> = _pairingExpiresAt.asStateFlow()
private val _pairingLoading = MutableStateFlow(false)
val pairingLoading: StateFlow<Boolean> = _pairingLoading.asStateFlow()
private var pairingPollJob: Job? = null
init {
// Sync accounts from backend on load
viewModelScope.launch {
@@ -65,6 +97,16 @@ class AccountsViewModel @Inject constructor(
}
}
fun toggleAccountEnabled(accountId: String, enabled: Boolean) {
viewModelScope.launch {
try {
accountRepository.setAccountEnabled(accountId, enabled)
} catch (e: Exception) {
_linkError.value = e.message ?: "Failed to update account"
}
}
}
fun unlinkAccount(accountId: String) {
viewModelScope.launch {
try {
@@ -75,7 +117,79 @@ 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
}
fun generatePairingCode() {
_pairingLoading.value = true
viewModelScope.launch {
try {
val response = apiService.generatePairingCode()
_pairingCode.value = response.code
_pairingExpiresAt.value = java.time.Instant.parse(response.expiresAt).toEpochMilli()
startPairingPoll()
} catch (_: Exception) {
_pairingCode.value = null
_pairingExpiresAt.value = null
} finally {
_pairingLoading.value = false
}
}
}
private fun startPairingPoll() {
pairingPollJob?.cancel()
pairingPollJob = viewModelScope.launch {
while (true) {
delay(3_000)
try {
val status = apiService.getPairingStatus()
if (!status.active) {
_pairingCode.value = null
_pairingExpiresAt.value = null
break
}
} catch (_: Exception) {}
val expiresAt = _pairingExpiresAt.value ?: break
if (System.currentTimeMillis() >= expiresAt) break
}
}
}
}

View File

@@ -0,0 +1,274 @@
package com.omixlab.lckcontrol.ui.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.omixlab.lckcontrol.data.remote.ChatMessage
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val YouTubeRed = Color(0xFFFF0000)
private val TwitchPurple = Color(0xFF9146FF)
private val PortalTeal = Color(0xFF00BCD4)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
onBack: () -> Unit,
viewModel: ChatViewModel = hiltViewModel(),
) {
val messagesMap by viewModel.allMessages.collectAsStateWithLifecycle()
val connectionMap by viewModel.allConnectionStatus.collectAsStateWithLifecycle()
val chatKey = viewModel.getChatKey()
val messages = messagesMap[chatKey] ?: emptyList()
val status = connectionMap[chatKey]
val isConnected = status?.connected ?: false
val serviceColor = when (viewModel.service) {
"YOUTUBE" -> YouTubeRed
"TWITCH" -> TwitchPurple
"PORTAL" -> PortalTeal
else -> TwitchPurple
}
val serviceLabel = when (viewModel.service) {
"YOUTUBE" -> "YouTube Chat"
"TWITCH" -> "Twitch Chat"
"PORTAL" -> "Portal Comments"
else -> "${viewModel.service} Chat"
}
var inputText by remember { mutableStateOf("") }
val listState = rememberLazyListState()
LaunchedEffect(Unit) {
viewModel.init()
}
// Auto-scroll to bottom when new messages arrive
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(messages.lastIndex)
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(serviceLabel)
Spacer(Modifier.width(8.dp))
Icon(
Icons.Default.Circle,
contentDescription = if (isConnected) "Connected" else "Disconnected",
tint = if (isConnected) Color(0xFF4CAF50) else Color(0xFFF44336),
modifier = Modifier.size(8.dp),
)
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
// Connection error banner
if (status?.error != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.errorContainer)
.padding(8.dp),
) {
Text(
"Connection error: ${status.error}",
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
)
}
}
// Messages list
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 8.dp),
state = listState,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
item { Spacer(Modifier.height(4.dp)) }
items(messages, key = { it.id }) { message ->
ChatMessageBubble(
message = message,
serviceColor = serviceColor,
isTwitch = viewModel.service == "TWITCH",
)
}
item { Spacer(Modifier.height(4.dp)) }
}
// Input bar
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = inputText,
onValueChange = { inputText = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Send a message...") },
singleLine = true,
shape = RoundedCornerShape(24.dp),
)
Spacer(Modifier.width(8.dp))
IconButton(
onClick = {
viewModel.sendMessage(inputText)
inputText = ""
},
enabled = inputText.isNotBlank() && isConnected,
) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
tint = if (inputText.isNotBlank() && isConnected)
serviceColor
else
MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun ChatMessageBubble(
message: ChatMessage,
serviceColor: Color,
isTwitch: Boolean,
) {
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
val timeStr = timeFormat.format(Date(message.timestamp))
// Parse Twitch color
val nameColor = if (isTwitch && !message.color.isNullOrBlank()) {
try {
Color(android.graphics.Color.parseColor(message.color))
} catch (_: Exception) {
serviceColor
}
} else {
serviceColor
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
),
shape = RoundedCornerShape(8.dp),
) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
// Badges
if (message.isBroadcaster) {
Badge("BC", serviceColor)
Spacer(Modifier.width(4.dp))
} else if (message.isModerator) {
Badge("MOD", Color(0xFF00AD03))
Spacer(Modifier.width(4.dp))
}
Text(
text = message.authorName,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = nameColor,
)
Spacer(Modifier.weight(1f))
Text(
text = timeStr,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(2.dp))
Text(
text = message.text,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
@Composable
private fun Badge(label: String, color: Color) {
Box(
modifier = Modifier
.clip(CircleShape)
.background(color)
.padding(horizontal = 4.dp, vertical = 1.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = Color.White,
fontWeight = FontWeight.Bold,
)
}
}

View File

@@ -0,0 +1,42 @@
package com.omixlab.lckcontrol.ui.chat
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import com.omixlab.lckcontrol.data.remote.ChatConnectionStatus
import com.omixlab.lckcontrol.data.remote.ChatMessage
import com.omixlab.lckcontrol.data.repository.ChatRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@HiltViewModel
class ChatViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val chatRepository: ChatRepository,
) : ViewModel() {
val planId: String = savedStateHandle["planId"] ?: ""
val service: String = savedStateHandle["service"] ?: ""
val destinationId: String = savedStateHandle["destinationId"] ?: ""
private val chatKey = "$planId:$service:$destinationId"
val allMessages: StateFlow<Map<String, List<ChatMessage>>> = chatRepository.messages
val allConnectionStatus: StateFlow<Map<String, ChatConnectionStatus>> = chatRepository.connectionStatus
fun getChatKey(): String = chatKey
fun init() {
chatRepository.setActiveView(chatKey)
}
fun sendMessage(text: String) {
if (text.isBlank()) return
chatRepository.sendMessage(planId, destinationId, text)
}
override fun onCleared() {
chatRepository.clearActiveView()
super.onCleared()
}
}

View File

@@ -29,10 +29,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.omixlab.lckcontrol.ui.components.GameInfoRow
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ActiveClientsScreen(
onNavigateToPlan: (String) -> Unit = {},
viewModel: ActiveClientsViewModel = hiltViewModel(),
) {
val clients by viewModel.clients.collectAsStateWithLifecycle()
@@ -116,10 +118,11 @@ fun ActiveClientsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(client.clientName, style = MaterialTheme.typography.titleSmall)
Text(
client.packageName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
Spacer(Modifier.height(4.dp))
GameInfoRow(
packageName = client.packageName,
gameInfoProvider = viewModel.gameInfoProvider,
iconSize = 24.dp,
)
}
if (client.activePlanId != null) {
@@ -133,7 +136,12 @@ fun ActiveClientsScreen(
if (client.activePlanId == null) {
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = { viewModel.createDefaultPlan(client.clientName) },
onClick = {
val plan = viewModel.createDefaultPlan(client.clientName)
if (plan != null) {
onNavigateToPlan(plan.planId)
}
},
) {
Text("Create Default Plan")
}

View File

@@ -10,6 +10,7 @@ import com.omixlab.lckcontrol.shared.ConnectedClientInfo
import com.omixlab.lckcontrol.shared.ILckControlCallback
import com.omixlab.lckcontrol.shared.ILckControlService
import com.omixlab.lckcontrol.shared.StreamPlan
import com.omixlab.lckcontrol.util.GameInfoProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
@@ -20,6 +21,7 @@ import javax.inject.Inject
@HiltViewModel
class ActiveClientsViewModel @Inject constructor(
@ApplicationContext private val context: Context,
val gameInfoProvider: GameInfoProvider,
) : ViewModel() {
private var service: ILckControlService? = null

View File

@@ -0,0 +1,62 @@
package com.omixlab.lckcontrol.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.omixlab.lckcontrol.util.GameInfoProvider
@Composable
fun GameInfoRow(
packageName: String,
gameInfoProvider: GameInfoProvider,
iconSize: Dp = 24.dp,
showPackageName: Boolean = false,
) {
val gameInfo = remember(packageName) { gameInfoProvider.resolve(packageName) }
Row(verticalAlignment = Alignment.CenterVertically) {
if (gameInfo != null) {
Image(
bitmap = gameInfo.icon,
contentDescription = gameInfo.label,
modifier = Modifier.size(iconSize),
)
Spacer(Modifier.width(8.dp))
Text(gameInfo.label, style = MaterialTheme.typography.bodyMedium)
if (showPackageName) {
Spacer(Modifier.width(4.dp))
Text(
"(${gameInfo.packageName})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
Icon(
Icons.Default.SportsEsports,
contentDescription = "Game",
modifier = Modifier.size(iconSize),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
Text(
packageName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View File

@@ -0,0 +1,293 @@
package com.omixlab.lckcontrol.ui.cortex
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
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.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.runtime.remember
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.omixlab.lckcontrol.cortex.CortexSession
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CortexScreen(
viewModel: CortexViewModel = hiltViewModel(),
) {
val sessions by viewModel.sessions.collectAsStateWithLifecycle()
val isRecording by viewModel.isRecording.collectAsStateWithLifecycle()
val storageUsedBytes by viewModel.storageUsedBytes.collectAsStateWithLifecycle()
val isEnabled by viewModel.isEnabled.collectAsStateWithLifecycle()
val maxMinutes by viewModel.maxMinutes.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Cortex")
if (isRecording) {
Spacer(Modifier.width(8.dp))
RecordingIndicator()
}
}
},
actions = {
if (sessions.isNotEmpty()) {
IconButton(onClick = { viewModel.deleteAllSessions() }) {
Icon(Icons.Default.DeleteSweep, contentDescription = "Delete all")
}
}
},
)
},
) { innerPadding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Enable/Disable toggle
item {
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text("Background Recording", style = MaterialTheme.typography.titleMedium)
Text(
"Continuously record gameplay in the background",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = isEnabled,
onCheckedChange = { viewModel.setEnabled(it) },
)
}
}
}
// Duration presets
if (isEnabled) {
item {
Column {
Text(
"Buffer Duration",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(bottom = 8.dp),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf(5, 10, 15, 20).forEach { minutes ->
FilterChip(
selected = maxMinutes == minutes,
onClick = { viewModel.setMaxMinutes(minutes) },
label = { Text("${minutes}m") },
)
}
}
}
}
}
// Storage card
item {
val availableBytes = viewModel.getAvailableStorageBytes()
val usedMb = storageUsedBytes / (1024.0 * 1024.0)
val availableGb = availableBytes / (1024.0 * 1024.0 * 1024.0)
val progress = if (availableBytes > 0) {
(storageUsedBytes.toFloat() / availableBytes).coerceIn(0f, 1f)
} else 0f
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Storage", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(4.dp))
Text(
"Using %.1f MB of %.1f GB available".format(usedMb, availableGb),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Session list
if (sessions.isEmpty()) {
item {
Text(
"No recordings yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 24.dp),
)
}
}
items(sessions, key = { it.sessionId }) { session ->
SessionCard(
session = session,
onDelete = { viewModel.deleteSession(session.sessionId) },
)
}
// Bottom spacer
item { Spacer(Modifier.height(16.dp)) }
}
}
}
@Composable
private fun RecordingIndicator() {
val infiniteTransition = rememberInfiniteTransition(label = "recording")
val alpha by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 0.2f,
animationSpec = infiniteRepeatable(
animation = tween(800),
repeatMode = RepeatMode.Reverse,
),
label = "pulse",
)
Icon(
Icons.Default.Circle,
contentDescription = "Recording",
tint = Color.Red,
modifier = Modifier
.size(12.dp)
.alpha(alpha),
)
}
@Composable
private fun SessionCard(
session: CortexSession,
onDelete: () -> Unit,
) {
val dateFormat = SimpleDateFormat("MMM d, h:mm a", Locale.getDefault())
val sizeMb = session.totalSizeBytes / (1024.0 * 1024.0)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(modifier = Modifier.padding(12.dp)) {
// Thumbnail row
if (session.thumbnailPaths.isNotEmpty()) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.fillMaxWidth()
.height(80.dp),
) {
items(session.thumbnailPaths) { thumbPath ->
val bitmap = remember(thumbPath) {
BitmapFactory.decodeFile(thumbPath)
}
if (bitmap != null) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(width = 120.dp, height = 80.dp)
.clip(RoundedCornerShape(4.dp)),
)
}
}
}
Spacer(Modifier.height(8.dp))
}
// Info row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
dateFormat.format(Date(session.startTime)),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
Text(
"${session.segmentCount} segments, %.1f MB".format(sizeMb),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete session",
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
}

View File

@@ -0,0 +1,67 @@
package com.omixlab.lckcontrol.ui.cortex
import android.os.StatFs
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.omixlab.lckcontrol.cortex.CortexManager
import com.omixlab.lckcontrol.cortex.CortexPreferences
import com.omixlab.lckcontrol.cortex.CortexSession
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
@HiltViewModel
class CortexViewModel @Inject constructor(
private val cortexManager: CortexManager,
private val cortexPreferences: CortexPreferences,
) : ViewModel() {
val sessions: StateFlow<List<CortexSession>> = cortexManager.sessions
val isRecording: StateFlow<Boolean> = cortexManager.isRecording
val storageUsedBytes: StateFlow<Long> = cortexManager.storageUsedBytes
private val _isEnabled = MutableStateFlow(cortexPreferences.isEnabled())
val isEnabled: StateFlow<Boolean> = _isEnabled.asStateFlow()
private val _maxMinutes = MutableStateFlow(cortexPreferences.getMaxMinutes())
val maxMinutes: StateFlow<Int> = _maxMinutes.asStateFlow()
init {
cortexManager.refreshSessions()
}
fun setEnabled(enabled: Boolean) {
cortexPreferences.setEnabled(enabled)
_isEnabled.value = enabled
}
fun setMaxMinutes(minutes: Int) {
cortexPreferences.setMaxMinutes(minutes)
_maxMinutes.value = minutes
}
fun deleteSession(sessionId: String) {
viewModelScope.launch {
cortexManager.deleteSession(sessionId)
}
}
fun deleteAllSessions() {
viewModelScope.launch {
cortexManager.deleteAllSessions()
}
}
fun getAvailableStorageBytes(): Long {
return try {
val stat = StatFs(File("/data").absolutePath)
stat.availableBlocksLong * stat.blockSizeLong
} catch (_: Exception) {
0L
}
}
}

View File

@@ -1,5 +1,7 @@
package com.omixlab.lckcontrol.ui.dashboard
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -9,40 +11,69 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.ClearAll
import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
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.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.omixlab.lckcontrol.data.remote.ChatConnectionStatus
import com.omixlab.lckcontrol.shared.StreamDestination
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)
@Composable
fun DashboardScreen(
onNavigateToCreatePlan: () -> Unit,
onNavigateToPlan: (String) -> Unit,
onNavigateToChat: (planId: String, service: String, destinationId: String) -> Unit = { _, _, _ -> },
viewModel: DashboardViewModel = hiltViewModel(),
) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val plans by viewModel.plans.collectAsStateWithLifecycle()
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()
val unreadCounts by viewModel.unreadCounts.collectAsStateWithLifecycle()
val chatStatus by viewModel.chatConnectionStatus.collectAsStateWithLifecycle()
val accountNames by viewModel.accountDisplayNames.collectAsStateWithLifecycle()
val isPublic by viewModel.isPublic.collectAsStateWithLifecycle()
val context = LocalContext.current
Scaffold(
topBar = {
@@ -63,44 +94,172 @@ fun DashboardScreen(
) {
item {
Spacer(Modifier.height(8.dp))
Text("Linked Accounts", style = MaterialTheme.typography.titleMedium)
Text("Server Status", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
}
if (accounts.isEmpty()) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"No accounts linked yet. Go to Accounts to get started.",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
val (color, label) = when (backendHealthy) {
true -> MaterialTheme.colorScheme.primary to "Connected"
false -> MaterialTheme.colorScheme.error to "Unreachable"
null -> MaterialTheme.colorScheme.outline to "Checking..."
}
Icon(
Icons.Default.Circle,
contentDescription = label,
tint = color,
modifier = Modifier.size(12.dp),
)
}
}
} else {
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
accounts.forEach { account ->
ElevatedCard {
Column(modifier = Modifier.padding(12.dp)) {
Text(account.displayName, style = MaterialTheme.typography.labelLarge)
Text(account.serviceId, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.width(12.dp))
Column {
Text("Backend", style = MaterialTheme.typography.titleSmall)
Text(label, style = MaterialTheme.typography.bodySmall, color = color)
if (backendVersion != null) {
Text(
"v$backendVersion",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
// 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)
}
}
// Live destinations section — shows each service with chat + browser buttons
val liveDestinations = plans.flatMap { plan ->
if (plan.status == "LIVE") {
val dests = plan.destinations.filter {
it.service == "YOUTUBE" || it.service == "TWITCH"
}.map { dest -> Triple(plan.planId, dest.service, dest) }
// Add synthetic PORTAL destination for live plans
val portalDest = com.omixlab.lckcontrol.shared.StreamDestination(
service = "PORTAL",
linkedAccountId = "portal",
title = "Portal Comments",
status = "LIVE",
)
dests + Triple(plan.planId, "PORTAL", portalDest)
} else emptyList()
}
if (liveDestinations.isNotEmpty()) {
item {
Spacer(Modifier.height(8.dp))
Text("Live", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
}
items(liveDestinations, key = { "${it.first}:${it.second}:${it.third.linkedAccountId}" }) { (planId, service, dest) ->
val chatKey = "$planId:$service:${dest.linkedAccountId}"
val status = chatStatus[chatKey]
val unread = unreadCounts[chatKey] ?: 0
LiveDestinationCard(
service = service,
destination = dest,
connectionStatus = status,
unreadCount = unread,
onChatClick = { onNavigateToChat(planId, service, dest.linkedAccountId) },
onBrowserClick = {
val url = when (service) {
"YOUTUBE" -> "https://youtube.com/watch?v=${dest.broadcastId}"
"TWITCH" -> {
val name = accountNames[dest.linkedAccountId] ?: ""
"https://twitch.tv/$name"
}
else -> null
}
if (url != null) {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
},
)
}
}
item {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Public Profile", style = MaterialTheme.typography.titleSmall)
Text(
"Allow others to find your profile",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = isPublic,
onCheckedChange = viewModel::setPublicVisibility,
)
}
}
}
item {
Spacer(Modifier.height(8.dp))
Text("Stream Plans", style = MaterialTheme.typography.titleMedium)
Text("Default Streaming Mode", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = defaultExecutionMode == "IN_GAME",
onClick = { viewModel.setDefaultExecutionMode("IN_GAME") },
label = { Text("In-Game") },
)
FilterChip(
selected = defaultExecutionMode == "APP_STREAMING",
onClick = { viewModel.setDefaultExecutionMode("APP_STREAMING") },
label = { Text("App Streaming") },
)
}
}
item {
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Stream Plans", style = MaterialTheme.typography.titleMedium)
if (plans.any { it.status == "ENDED" || it.status == "DRAFT" }) {
IconButton(onClick = viewModel::clearStalePlans) {
Icon(
Icons.Default.ClearAll,
contentDescription = "Clear ended and draft plans",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
if (plans.isEmpty()) {
@@ -120,7 +279,11 @@ fun DashboardScreen(
}
} else {
items(plans, key = { it.planId }) { plan ->
PlanCard(plan = plan, onClick = { onNavigateToPlan(plan.planId) })
PlanCard(
plan = plan,
gameInfoProvider = viewModel.gameInfoProvider,
onClick = { onNavigateToPlan(plan.planId) },
)
}
}
@@ -130,7 +293,11 @@ fun DashboardScreen(
}
@Composable
private fun PlanCard(plan: StreamPlan, onClick: () -> Unit) {
private fun PlanCard(
plan: StreamPlan,
gameInfoProvider: GameInfoProvider,
onClick: () -> Unit,
) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
@@ -151,6 +318,107 @@ private fun PlanCard(plan: StreamPlan, onClick: () -> Unit) {
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (plan.gameId.isNotBlank()) {
Spacer(Modifier.height(8.dp))
GameInfoRow(
packageName = plan.gameId,
gameInfoProvider = gameInfoProvider,
iconSize = 24.dp,
)
}
}
}
}
private val YouTubeRed = Color(0xFFFF0000)
private val TwitchPurple = Color(0xFF9146FF)
private val PortalTeal = Color(0xFF00BCD4)
@Composable
private fun LiveDestinationCard(
service: String,
destination: StreamDestination,
connectionStatus: ChatConnectionStatus?,
unreadCount: Int,
onChatClick: () -> Unit,
onBrowserClick: () -> Unit,
) {
val serviceColor = when (service) {
"YOUTUBE" -> YouTubeRed
"TWITCH" -> TwitchPurple
"PORTAL" -> PortalTeal
else -> TwitchPurple
}
val serviceLabel = when (service) {
"YOUTUBE" -> "YouTube"
"TWITCH" -> "Twitch"
"PORTAL" -> "Portal"
else -> service
}
val hasFailed = connectionStatus != null && !connectionStatus.connected && connectionStatus.error != null
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
if (hasFailed) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.ErrorOutline,
contentDescription = "Error",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(serviceLabel, style = MaterialTheme.typography.titleSmall)
Text(
connectionStatus?.error ?: "Failed to start",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
} else {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.Circle,
contentDescription = null,
tint = serviceColor,
modifier = Modifier.size(12.dp),
)
Spacer(Modifier.width(8.dp))
Text(
serviceLabel,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
BadgedBox(badge = {
if (unreadCount > 0) {
Badge { Text(if (unreadCount > 99) "99+" else unreadCount.toString()) }
}
}) {
IconButton(onClick = onChatClick) {
Icon(
Icons.AutoMirrored.Filled.Chat,
contentDescription = "$serviceLabel Chat",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (service != "PORTAL") {
IconButton(onClick = onBrowserClick) {
Icon(
Icons.Default.OpenInBrowser,
contentDescription = "Open in browser",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}

View File

@@ -2,32 +2,118 @@ package com.omixlab.lckcontrol.ui.dashboard
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.omixlab.lckcontrol.data.local.AppPreferences
import com.omixlab.lckcontrol.data.remote.ChatConnectionStatus
import com.omixlab.lckcontrol.data.remote.LckApiService
import com.omixlab.lckcontrol.data.repository.AccountRepository
import com.omixlab.lckcontrol.data.repository.ChatRepository
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
import com.omixlab.lckcontrol.shared.LinkedAccount
import com.omixlab.lckcontrol.data.remote.UpdateProfileRequest
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
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class DashboardViewModel @Inject constructor(
accountRepository: AccountRepository,
private val streamPlanRepository: StreamPlanRepository,
private val apiService: LckApiService,
private val appPreferences: AppPreferences,
val gameInfoProvider: GameInfoProvider,
private val streamingManager: StreamingManager,
private val chatRepository: ChatRepository,
private val accountRepository: AccountRepository,
) : ViewModel() {
val accounts: StateFlow<List<LinkedAccount>> = accountRepository.observeAccounts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val plans: StateFlow<List<StreamPlan>> = streamPlanRepository.observePlans()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val streamingState: StateFlow<StreamingState> = streamingManager.state
val streamingStats: StateFlow<StreamingStats> = streamingManager.stats
val streamingManagerInstance: StreamingManager = streamingManager
val unreadCounts: StateFlow<Map<String, Int>> = chatRepository.unreadCounts
val chatConnectionStatus: StateFlow<Map<String, ChatConnectionStatus>> = chatRepository.connectionStatus
// Linked account display names keyed by account ID (for building Twitch URLs)
private val _accountDisplayNames = MutableStateFlow<Map<String, String>>(emptyMap())
val accountDisplayNames: StateFlow<Map<String, String>> = _accountDisplayNames.asStateFlow()
private val _backendHealthy = MutableStateFlow<Boolean?>(null)
val backendHealthy: StateFlow<Boolean?> = _backendHealthy.asStateFlow()
private val _backendVersion = MutableStateFlow<String?>(null)
val backendVersion: StateFlow<String?> = _backendVersion.asStateFlow()
private val _defaultExecutionMode = MutableStateFlow(appPreferences.getDefaultExecutionMode())
val defaultExecutionMode: StateFlow<String> = _defaultExecutionMode.asStateFlow()
private val _isPublic = MutableStateFlow(false)
val isPublic: StateFlow<Boolean> = _isPublic.asStateFlow()
init {
viewModelScope.launch {
try { streamPlanRepository.syncPlans() } catch (_: Exception) {}
}
viewModelScope.launch {
try {
val profile = apiService.getMe()
_isPublic.value = profile.isPublic
} catch (_: Exception) {}
}
viewModelScope.launch {
try {
val accounts = accountRepository.getAccounts()
_accountDisplayNames.value = accounts.associate { it.id to it.displayName }
} catch (_: Exception) {}
}
viewModelScope.launch {
while (true) {
try {
val response = apiService.healthCheck()
_backendHealthy.value = true
_backendVersion.value = response.version
} catch (_: Exception) {
_backendHealthy.value = false
}
delay(5_000)
}
}
}
fun setDefaultExecutionMode(mode: String) {
_defaultExecutionMode.value = mode
appPreferences.setDefaultExecutionMode(mode)
}
fun setPublicVisibility(isPublic: Boolean) {
_isPublic.value = isPublic
viewModelScope.launch {
try {
apiService.updateProfile(UpdateProfileRequest(isPublic = isPublic))
} catch (_: Exception) {
_isPublic.value = !isPublic // revert on error
}
}
}
fun clearStalePlans() {
viewModelScope.launch {
val stale = plans.value.filter { it.status == "ENDED" || it.status == "DRAFT" }
for (plan in stale) {
try { streamPlanRepository.deletePlan(plan.planId) } catch (_: Exception) {}
}
}
}
}

View File

@@ -1,15 +1,12 @@
package com.omixlab.lckcontrol.ui.navigation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.FiberSmartRecord
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
@@ -17,13 +14,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
@@ -35,11 +27,12 @@ import com.omixlab.lckcontrol.data.local.TokenStore
import com.omixlab.lckcontrol.data.remote.LckApiService
import com.omixlab.lckcontrol.ui.accounts.AccountsScreen
import com.omixlab.lckcontrol.ui.clients.ActiveClientsScreen
import com.omixlab.lckcontrol.ui.cortex.CortexScreen
import com.omixlab.lckcontrol.ui.dashboard.DashboardScreen
import com.omixlab.lckcontrol.ui.login.LoginScreen
import com.omixlab.lckcontrol.ui.chat.ChatScreen
import com.omixlab.lckcontrol.ui.plans.CreatePlanScreen
import com.omixlab.lckcontrol.ui.plans.PlanDetailScreen
import kotlinx.coroutines.delay
private data class BottomNavItem(
val screen: Screen,
@@ -50,6 +43,7 @@ private data class BottomNavItem(
private val bottomNavItems = listOf(
BottomNavItem(Screen.Dashboard, "Dashboard", Icons.Default.Dashboard),
BottomNavItem(Screen.Accounts, "Accounts", Icons.Default.Person),
BottomNavItem(Screen.Cortex, "Cortex", Icons.Default.FiberSmartRecord),
BottomNavItem(Screen.ActiveClients, "Clients", Icons.Default.Devices),
)
@@ -62,22 +56,6 @@ fun AppNavigation(tokenStore: TokenStore, apiService: LckApiService) {
val showBottomBar = currentRoute in bottomNavItems.map { it.screen.route }
val startDestination = if (tokenStore.isLoggedIn()) Screen.Dashboard.route else Screen.Login.route
// Backend health state
var backendHealthy by remember { mutableStateOf<Boolean?>(null) }
// Poll backend health every 5 seconds
LaunchedEffect(Unit) {
while (true) {
backendHealthy = try {
apiService.healthCheck()
true
} catch (_: Exception) {
false
}
delay(5_000)
}
}
// Session validation on app open — if we think we're logged in, verify it
LaunchedEffect(Unit) {
if (tokenStore.isLoggedIn()) {
@@ -101,24 +79,7 @@ fun AppNavigation(tokenStore: TokenStore, apiService: LckApiService) {
bottomNavItems.forEach { item ->
NavigationBarItem(
icon = {
if (item.screen == Screen.Dashboard && backendHealthy != null) {
Box {
Icon(item.icon, contentDescription = item.label)
Icon(
Icons.Default.Circle,
contentDescription = if (backendHealthy == true) "Backend healthy" else "Backend unreachable",
tint = if (backendHealthy == true)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error,
modifier = Modifier
.size(8.dp)
.align(Alignment.TopEnd),
)
}
} else {
Icon(item.icon, contentDescription = item.label)
}
Icon(item.icon, contentDescription = item.label)
},
label = { Text(item.label) },
selected = currentRoute == item.screen.route,
@@ -153,16 +114,26 @@ fun AppNavigation(tokenStore: TokenStore, apiService: LckApiService) {
}
composable(Screen.Dashboard.route) {
DashboardScreen(
onNavigateToCreatePlan = { navController.navigate(Screen.CreatePlan.route) },
onNavigateToCreatePlan = { navController.navigate(Screen.CreatePlan.createRoute()) },
onNavigateToPlan = { planId ->
navController.navigate(Screen.PlanDetail.createRoute(planId))
},
onNavigateToChat = { planId, service, destinationId ->
navController.navigate(Screen.Chat.createRoute(planId, service, destinationId))
},
)
}
composable(Screen.Accounts.route) {
AccountsScreen()
}
composable(Screen.CreatePlan.route) {
composable(
route = Screen.CreatePlan.route,
arguments = listOf(navArgument("planId") {
type = NavType.StringType
nullable = true
defaultValue = null
}),
) {
CreatePlanScreen(
onPlanCreated = { planId ->
navController.navigate(Screen.PlanDetail.createRoute(planId)) {
@@ -180,10 +151,30 @@ fun AppNavigation(tokenStore: TokenStore, apiService: LckApiService) {
PlanDetailScreen(
planId = planId,
onBack = { navController.popBackStack() },
onNavigateToEditPlan = { id ->
navController.navigate(Screen.CreatePlan.createRoute(id))
},
)
}
composable(
route = Screen.Chat.route,
arguments = listOf(
navArgument("planId") { type = NavType.StringType },
navArgument("service") { type = NavType.StringType },
navArgument("destinationId") { type = NavType.StringType },
),
) {
ChatScreen(onBack = { navController.popBackStack() })
}
composable(Screen.Cortex.route) {
CortexScreen()
}
composable(Screen.ActiveClients.route) {
ActiveClientsScreen()
ActiveClientsScreen(
onNavigateToPlan = { planId ->
navController.navigate(Screen.PlanDetail.createRoute(planId))
},
)
}
}
}

View File

@@ -4,9 +4,17 @@ sealed class Screen(val route: String) {
data object Login : Screen("login")
data object Dashboard : Screen("dashboard")
data object Accounts : Screen("accounts")
data object CreatePlan : Screen("create_plan")
data object CreatePlan : Screen("create_plan?planId={planId}") {
fun createRoute(planId: String? = null) =
if (planId != null) "create_plan?planId=$planId" else "create_plan"
}
data object PlanDetail : Screen("plan_detail/{planId}") {
fun createRoute(planId: String) = "plan_detail/$planId"
}
data object Cortex : Screen("cortex")
data object ActiveClients : Screen("active_clients")
data object Chat : Screen("chat/{planId}/{service}/{destinationId}") {
fun createRoute(planId: String, service: String, destinationId: String) =
"chat/$planId/$service/$destinationId"
}
}

View File

@@ -20,6 +20,7 @@ import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -29,6 +30,7 @@ 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.TopAppBar
import androidx.compose.runtime.Composable
@@ -37,11 +39,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.omixlab.lckcontrol.shared.LinkedAccount
import com.omixlab.lckcontrol.ui.components.GameInfoRow
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -50,7 +54,12 @@ fun CreatePlanScreen(
onBack: () -> Unit,
viewModel: CreatePlanViewModel = hiltViewModel(),
) {
val isEditMode = viewModel.isEditMode
val planName by viewModel.planName.collectAsStateWithLifecycle()
val executionMode by viewModel.executionMode.collectAsStateWithLifecycle()
val gameId by viewModel.gameId.collectAsStateWithLifecycle()
val isPublic by viewModel.isPublic.collectAsStateWithLifecycle()
val connectedClients by viewModel.connectedClients.collectAsStateWithLifecycle()
val destinations by viewModel.destinations.collectAsStateWithLifecycle()
val linkedAccounts by viewModel.linkedAccounts.collectAsStateWithLifecycle()
val isCreating by viewModel.isCreating.collectAsStateWithLifecycle()
@@ -67,7 +76,7 @@ fun CreatePlanScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Create Stream Plan") },
title = { Text(if (isEditMode) "Edit Stream Plan" else "Create Stream Plan") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
@@ -95,6 +104,74 @@ fun CreatePlanScreen(
)
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Publish to Portal", style = MaterialTheme.typography.titleSmall)
Text(
"Show this stream in the public feed",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = isPublic,
onCheckedChange = viewModel::setIsPublic,
)
}
}
item {
Spacer(Modifier.height(8.dp))
Text("Execution Mode", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = executionMode == "IN_GAME",
onClick = { viewModel.setExecutionMode("IN_GAME") },
label = { Text("In-Game") },
)
FilterChip(
selected = executionMode == "APP_STREAMING",
onClick = { viewModel.setExecutionMode("APP_STREAMING") },
label = { Text("App Streaming") },
)
}
if (executionMode == "APP_STREAMING") {
Spacer(Modifier.height(4.dp))
Text(
"The app encodes and streams. Stream keys stay secure.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
item {
Text("Game", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
if (gameId.isNotBlank()) {
GameInfoRow(
packageName = gameId,
gameInfoProvider = viewModel.gameInfoProvider,
showPackageName = true,
)
} else if (connectedClients.isEmpty()) {
Text(
"No game connected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
item {
Spacer(Modifier.height(8.dp))
Row(
@@ -122,11 +199,18 @@ fun CreatePlanScreen(
item {
Spacer(Modifier.height(16.dp))
Button(
onClick = { viewModel.createPlan(onPlanCreated) },
onClick = { viewModel.savePlan(onPlanCreated) },
modifier = Modifier.fillMaxWidth(),
enabled = !isCreating,
) {
Text(if (isCreating) "Creating..." else "Create Plan")
Text(
when {
isCreating && isEditMode -> "Saving..."
isCreating -> "Creating..."
isEditMode -> "Save"
else -> "Create Plan"
}
)
}
Spacer(Modifier.height(16.dp))
}
@@ -160,13 +244,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") },
@@ -179,15 +263,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
},
)
@@ -195,6 +303,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)) },
@@ -203,52 +330,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,
)
}
}
}
}

View File

@@ -1,12 +1,25 @@
package com.omixlab.lckcontrol.ui.plans
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.omixlab.lckcontrol.data.repository.AccountRepository
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
import com.omixlab.lckcontrol.shared.ConnectedClientInfo
import com.omixlab.lckcontrol.shared.ILckControlCallback
import com.omixlab.lckcontrol.shared.ILckControlService
import com.omixlab.lckcontrol.shared.LinkedAccount
import com.omixlab.lckcontrol.shared.StreamDestination
import com.omixlab.lckcontrol.shared.StreamPlan
import com.omixlab.lckcontrol.data.local.AppPreferences
import com.omixlab.lckcontrol.util.GameInfoProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -23,20 +36,42 @@ data class DestinationInput(
val privacyStatus: String = "public",
val gameId: String = "",
val tags: String = "",
val isCustom: Boolean = false,
val rtmpUrl: String = "",
val streamKey: String = "",
)
@HiltViewModel
class CreatePlanViewModel @Inject constructor(
accountRepository: AccountRepository,
@ApplicationContext private val context: Context,
savedStateHandle: SavedStateHandle,
private val accountRepository: AccountRepository,
private val streamPlanRepository: StreamPlanRepository,
private val appPreferences: AppPreferences,
val gameInfoProvider: GameInfoProvider,
) : ViewModel() {
val editingPlanId: String? = savedStateHandle.get<String>("planId")
val isEditMode: Boolean = editingPlanId != null
val linkedAccounts: StateFlow<List<LinkedAccount>> = accountRepository.observeAccounts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _planName = MutableStateFlow("")
val planName: StateFlow<String> = _planName.asStateFlow()
private val _executionMode = MutableStateFlow(appPreferences.getDefaultExecutionMode())
val executionMode: StateFlow<String> = _executionMode.asStateFlow()
private val _gameId = MutableStateFlow("")
val gameId: StateFlow<String> = _gameId.asStateFlow()
private val _isPublic = MutableStateFlow(true)
val isPublic: StateFlow<Boolean> = _isPublic.asStateFlow()
private val _connectedClients = MutableStateFlow<List<ConnectedClientInfo>>(emptyList())
val connectedClients: StateFlow<List<ConnectedClientInfo>> = _connectedClients.asStateFlow()
private val _destinations = MutableStateFlow<List<DestinationInput>>(emptyList())
val destinations: StateFlow<List<DestinationInput>> = _destinations.asStateFlow()
@@ -46,10 +81,105 @@ class CreatePlanViewModel @Inject constructor(
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
private var service: ILckControlService? = null
private val callback = object : ILckControlCallback.Stub() {
override fun onStreamPlansChanged(plans: List<StreamPlan>) {}
override fun onStreamPlanUpdated(plan: StreamPlan) {}
override fun onClientRegistered(clientId: String) { refreshClients() }
override fun onClientUnregistered(clientId: String) { refreshClients() }
override fun onAuthStateChanged(authenticated: Boolean) {}
}
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
service = ILckControlService.Stub.asInterface(binder)
service?.registerCallback(callback)
refreshClients()
}
override fun onServiceDisconnected(name: ComponentName?) {
service = null
_connectedClients.value = emptyList()
}
}
init {
bindToService()
if (isEditMode) {
loadExistingPlan()
}
}
private fun bindToService() {
val intent = Intent().apply {
component = ComponentName(
context.packageName,
"com.omixlab.lckcontrol.service.LckControlService",
)
}
context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
private fun refreshClients() {
val clients = service?.connectedClients ?: emptyList()
_connectedClients.value = clients
// Auto-fill gameId from first connected client when empty
if (_gameId.value.isBlank() && clients.isNotEmpty()) {
_gameId.value = clients.first().packageName
}
}
private fun loadExistingPlan() {
viewModelScope.launch {
try {
val plan = streamPlanRepository.getPlan(editingPlanId!!) ?: return@launch
_planName.value = plan.name
_executionMode.value = plan.executionMode
_gameId.value = plan.gameId
_isPublic.value = plan.isPublic
// Wait for linked accounts to load for label resolution
val accounts = accountRepository.getAccounts()
_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,
title = dest.title,
description = dest.description,
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) {
_error.value = e.message ?: "Failed to load plan"
}
}
}
fun setPlanName(name: String) {
_planName.value = name
}
fun setExecutionMode(mode: String) {
_executionMode.value = mode
}
fun setGameId(gameId: String) {
_gameId.value = gameId
}
fun setIsPublic(isPublic: Boolean) {
_isPublic.value = isPublic
}
fun addDestination() {
_destinations.value = _destinations.value + DestinationInput()
}
@@ -66,7 +196,7 @@ class CreatePlanViewModel @Inject constructor(
}
}
fun createPlan(onCreated: (String) -> Unit) {
fun savePlan(onSaved: (String) -> Unit) {
val name = _planName.value.trim()
val dests = _destinations.value
@@ -78,9 +208,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 {
@@ -89,21 +230,34 @@ 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 = streamPlanRepository.createPlan(name, streamDests)
onCreated(plan.planId)
val plan = if (isEditMode) {
streamPlanRepository.updatePlan(editingPlanId!!, name, streamDests, _executionMode.value, _gameId.value, _isPublic.value)
} else {
streamPlanRepository.createPlan(name, streamDests, _executionMode.value, _gameId.value, _isPublic.value)
}
onSaved(plan.planId)
} catch (e: Exception) {
_error.value = e.message ?: "Failed to create plan"
_error.value = e.message ?: "Failed to ${if (isEditMode) "update" else "create"} plan"
} finally {
_isCreating.value = false
}
@@ -113,4 +267,12 @@ class CreatePlanViewModel @Inject constructor(
fun clearError() {
_error.value = null
}
override fun onCleared() {
service?.unregisterCallback(callback)
try {
context.unbindService(serviceConnection)
} catch (_: IllegalArgumentException) {}
super.onCleared()
}
}

View File

@@ -14,6 +14,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
@@ -40,17 +41,22 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.omixlab.lckcontrol.shared.StreamDestination
import com.omixlab.lckcontrol.streaming.StreamingState
import com.omixlab.lckcontrol.ui.components.GameInfoRow
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlanDetailScreen(
planId: String,
onBack: () -> Unit,
onNavigateToEditPlan: (String) -> Unit = {},
viewModel: PlanDetailViewModel = hiltViewModel(),
) {
val plan by viewModel.plan.collectAsStateWithLifecycle()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
val streamingStats by viewModel.streamingStats.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(error) {
@@ -70,6 +76,11 @@ fun PlanDetailScreen(
}
},
actions = {
if (plan?.status == "DRAFT") {
IconButton(onClick = { onNavigateToEditPlan(planId) }) {
Icon(Icons.Default.Edit, contentDescription = "Edit")
}
}
if (plan?.status != "LIVE") {
IconButton(onClick = { viewModel.deletePlan(onBack) }) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
@@ -122,6 +133,41 @@ fun PlanDetailScreen(
}
}
// Execution mode
item {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Execution Mode", style = MaterialTheme.typography.labelMedium)
Spacer(Modifier.height(4.dp))
Text(
when (currentPlan.executionMode) {
"APP_STREAMING" -> "App Streaming"
else -> "In-Game"
},
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
// Game
if (currentPlan.gameId.isNotBlank()) {
item {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Game", style = MaterialTheme.typography.labelMedium)
Spacer(Modifier.height(4.dp))
GameInfoRow(
packageName = currentPlan.gameId,
gameInfoProvider = viewModel.gameInfoProvider,
showPackageName = true,
)
}
}
}
}
// Action buttons
item {
when (currentPlan.status) {
@@ -159,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))

View File

@@ -5,6 +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.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
@@ -18,6 +23,8 @@ import javax.inject.Inject
class PlanDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val streamPlanRepository: StreamPlanRepository,
val streamingManager: StreamingManager,
val gameInfoProvider: GameInfoProvider,
) : ViewModel() {
private val planId: String = savedStateHandle["planId"] ?: ""
@@ -32,6 +39,9 @@ class PlanDetailViewModel @Inject constructor(
}
}
val streamingState: StateFlow<StreamingState> = streamingManager.state
val streamingStats: StateFlow<StreamingStats> = streamingManager.stats
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
@@ -58,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 {
@@ -71,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"

View File

@@ -0,0 +1,56 @@
package com.omixlab.lckcontrol.ui.plans
import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.omixlab.lckcontrol.streaming.StreamingManager
@Composable
fun StreamPreviewSurface(
streamingManager: StreamingManager,
modifier: Modifier = Modifier,
) {
DisposableEffect(streamingManager) {
onDispose {
streamingManager.removePreviewSurface()
}
}
AndroidView(
factory = { context ->
SurfaceView(context).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
streamingManager.setPreviewSurface(holder.surface)
}
override fun surfaceChanged(
holder: SurfaceHolder,
format: Int,
width: Int,
height: Int,
) {
// Surface size changed — re-set to update dimensions
streamingManager.setPreviewSurface(holder.surface)
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
streamingManager.removePreviewSurface()
}
})
}
},
modifier = modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(12.dp)),
)
}

View File

@@ -0,0 +1,52 @@
package com.omixlab.lckcontrol.ui.plans
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.omixlab.lckcontrol.streaming.StreamingStats
@Composable
fun StreamingStatsCard(stats: StreamingStats) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Streaming Stats", style = MaterialTheme.typography.titleSmall)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
StatItem("Video", formatBitrate(stats.videoBitrate))
StatItem("Audio", formatBitrate(stats.audioBitrate))
StatItem("FPS", "${stats.fps}")
StatItem("Dropped", "${stats.droppedFrames}")
}
}
}
}
@Composable
private fun StatItem(label: String, value: String) {
Column {
Text(label, style = MaterialTheme.typography.labelSmall)
Text(value, style = MaterialTheme.typography.bodyMedium)
}
}
private fun formatBitrate(bps: Long): String {
return when {
bps >= 1_000_000 -> "%.1f Mbps".format(bps / 1_000_000.0)
bps >= 1_000 -> "%.0f kbps".format(bps / 1_000.0)
else -> "$bps bps"
}
}

View File

@@ -0,0 +1,49 @@
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
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
data class GameInfo(
val packageName: String,
val label: String,
val icon: ImageBitmap,
)
@Singleton
class GameInfoProvider @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val cache = HashMap<String, GameInfo?>()
fun resolve(packageName: String): GameInfo? {
if (packageName.isBlank()) return null
return cache.getOrPut(packageName) {
try {
val pm = context.packageManager
val appInfo = pm.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
val label = pm.getApplicationLabel(appInfo).toString()
// 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)
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,745 @@
# LIV Control Center (Hub) vs LCK Control (Companion App)
## Comprehensive Architecture Comparison & Unification Strategy
---
## 1. Executive Summary
LIV has two parallel applications for managing game streaming on Quest:
| | **Hub** (`liv-control-center`) | **Control** (`lck-control`) |
|---|---|---|
| **Stage** | Production (Quest Store, low reviews) | Prototype (new architecture) |
| **Stack** | Rust + Tauri + Leptos (WASM) | Kotlin + Jetpack Compose + Hilt |
| **Streaming** | App captures screen & encodes | Game encodes directly from render pipeline |
| **Communication** | Async via backend server | Synchronous IPC via AIDL |
| **Destinations** | Single target | Multi-destination |
| **UE5 Plugin** | `LCKStreaming` (HTTP/JSON-RPC) | `LCKControl` (AIDL/JNI) |
---
## 2. High-Level Architecture
### 2.1 Hub Architecture
```mermaid
graph TB
subgraph Quest Headset
subgraph "Hub App (Tauri + Leptos WASM)"
UI_H["Leptos UI<br/>(WASM)"]
Core_H["Rust Core<br/>(Tauri Backend)"]
Encoder_H["MediaCodec<br/>H.264 + AAC"]
RTMP_H["minirtmp<br/>(RTMP Client)"]
Capture["ScreenCaptureService<br/>(MediaProjection)"]
end
subgraph "UE5 Game"
Plugin_S["LCKStreaming Plugin"]
API_Client["HTTP/JSON-RPC Client"]
end
end
subgraph "Cloud Server"
Backend_H["Hub Backend<br/>(api.obi.gg)"]
end
subgraph "Streaming Platforms"
YT["YouTube Live"]
TW["Twitch"]
end
UI_H <-->|Tauri IPC| Core_H
Core_H -->|JSON-RPC 2.0<br/>HTTPS + Cert Pinning| Backend_H
Plugin_S -->|JSON-RPC 2.0<br/>HTTPS| Backend_H
Backend_H -->|"Device Pairing<br/>(async polling)"| Plugin_S
Core_H --> Capture
Capture --> Encoder_H
Encoder_H --> RTMP_H
RTMP_H -->|RTMP| YT
RTMP_H -->|RTMP| TW
style Backend_H fill:#f96,stroke:#333
style Capture fill:#ff9,stroke:#333
style Encoder_H fill:#ff9,stroke:#333
```
**Key: The Hub app captures the screen, encodes it, and streams. The game and hub communicate indirectly through the backend server.**
### 2.2 Control App Architecture
```mermaid
graph TB
subgraph Quest Headset
subgraph "Control App (Kotlin + Compose)"
UI_C["Compose UI"]
VM["ViewModels + Repos"]
Service["LckControlService<br/>(Foreground + AIDL)"]
DB["Room DB<br/>(Local Cache)"]
end
subgraph "UE5 Game"
Plugin_C["LCKControl Plugin"]
JNI["JNI Bridge"]
SDK["lck-control-sdk<br/>(AAR)"]
Encoder_C["LCK Encoder<br/>(H.264 + AAC)"]
RTMP_C1["RTMP Sink 1"]
RTMP_C2["RTMP Sink 2"]
RTMP_CN["RTMP Sink N"]
end
end
subgraph "Self-Hosted Server"
Backend_C["Control Backend<br/>(Node.js + Fastify)"]
SQLite["SQLite DB"]
end
subgraph "Streaming Platforms"
YT2["YouTube Live"]
TW2["Twitch"]
Manual["Custom RTMP"]
end
UI_C <--> VM
VM <-->|REST API<br/>JWT Auth| Backend_C
VM <--> DB
VM <--> Service
Plugin_C --> JNI
JNI --> SDK
SDK <-->|"AIDL IPC<br/>(Bound Service)"| Service
Backend_C <--> SQLite
Backend_C -->|"OAuth + RTMP<br/>Resolution"| YT2
Backend_C -->|"OAuth + RTMP<br/>Resolution"| TW2
Encoder_C --> RTMP_C1
Encoder_C --> RTMP_C2
Encoder_C --> RTMP_CN
RTMP_C1 -->|RTMP| YT2
RTMP_C2 -->|RTMP| TW2
RTMP_CN -->|RTMP| Manual
style Service fill:#9f9,stroke:#333
style SDK fill:#9f9,stroke:#333
style Encoder_C fill:#9cf,stroke:#333
```
**Key: The game encodes directly from its render pipeline and streams to multiple destinations. The companion app provides stream configuration via direct IPC.**
---
## 3. Communication Model Comparison
### 3.1 Hub: Server-Mediated Async Communication
```mermaid
sequenceDiagram
participant Game as UE5 Game<br/>(LCKStreaming)
participant Server as Hub Backend<br/>(api.obi.gg)
participant Hub as Hub App<br/>(Tauri)
participant Platform as YouTube/Twitch
Note over Game,Hub: Device Pairing (one-time)
Game->>Server: create_device_login_attempt()
Server-->>Game: 6-digit pairing code
Game->>Game: Display code to user
Hub->>Server: pair_device(code)
Server-->>Hub: Device paired
Note over Game,Hub: Stream Setup
Hub->>Server: get_user_profile()
Server-->>Hub: Streaming target + RTMP URL
Hub->>Hub: Start screen capture
Hub->>Hub: Encode H.264 + AAC
Hub->>Platform: RTMP stream
Note over Game,Server: Game has no direct<br/>connection to Hub
Game->>Server: Poll for updates (2.5s)
Server-->>Game: Current state
```
### 3.2 Control: Direct IPC Communication
```mermaid
sequenceDiagram
participant Game as UE5 Game<br/>(LCKControl + JNI)
participant App as Control App<br/>(AIDL Service)
participant Server as Control Backend<br/>(Fastify)
participant Platform as YouTube/Twitch
Note over Game,App: Service Binding (direct)
Game->>App: bindService() via AIDL
App-->>Game: ILckControlService binder
Game->>App: registerAsClient("MyGame", pkg)
App-->>Game: clientId
Note over Game,Platform: Stream Lifecycle
Game->>App: getStreamPlans()
App-->>Game: List<StreamPlan>
Game->>App: prepareStreamPlan(planId)
App->>Server: POST /streams/plans/{id}/prepare
Server->>Platform: Create broadcast + get RTMP URLs
Platform-->>Server: RTMP URLs + stream keys
Server-->>App: PrepareResponse
App-->>Game: StreamPlan (with RTMP data)
Game->>Game: Encode from render pipeline
Game->>Platform: RTMP stream (dest 1)
Game->>Platform: RTMP stream (dest 2)
Game->>App: startStreamPlan(planId)
App->>Server: POST /streams/plans/{id}/start
Server->>Platform: Transition broadcast to LIVE
```
---
## 4. Technology Stack Comparison
### 4.1 Application Layer
| Component | Hub | Control |
|-----------|-----|---------|
| **Language** | Rust (95%) + Kotlin (JNI) | Kotlin (100%) |
| **UI Framework** | Leptos 0.8.2 (Rust WASM) | Jetpack Compose (2024.09 BOM) |
| **App Framework** | Tauri v2.6.2 | Native Android |
| **Styling** | TailwindCSS v4 | Material Design 3 |
| **State Mgmt** | Leptos reactive signals | StateFlow + collectAsStateWithLifecycle |
| **DI** | None (manual wiring) | Hilt 2.59.2 |
| **Navigation** | Leptos Router | Compose Navigation 2.8.4 |
| **Local Storage** | Platform credential store | Room 2.8.4 + EncryptedSharedPreferences |
| **HTTP Client** | reqwest (rustls TLS) | Retrofit 2.11.0 + OkHttp 4.12.0 |
| **JSON** | serde_json | Moshi 1.15.1 |
| **Auth SDK** | Meta Horizon Platform SDK 77.0.1 | Meta Horizon Platform SDK 77.0.1 |
| **Crash Reporting** | Sentry (Android SDK bridge) | None |
### 4.2 Backend Layer
| Component | Hub Backend | Control Backend |
|-----------|-------------|-----------------|
| **Hosting** | Cloud (`api.obi.gg`) | Self-hosted (Docker on NAS, port 3100) |
| **Protocol** | JSON-RPC 2.0 | REST (JSON) |
| **Stack** | Unknown (external) | Node.js 20 + Fastify 5 + TypeScript 5.7 |
| **Database** | Unknown | SQLite (Prisma 6.4 ORM) |
| **Auth** | JWT (via JSON-RPC response headers) | JWT HS256 (jose 6.0) |
| **Token Security** | Unknown | AES-256-GCM encryption + SHA256 hashing |
| **OAuth** | Server handles YouTube/Twitch | Server handles YouTube/Twitch |
| **Rate Limiting** | Unknown | 100 req/min (Fastify plugin) |
| **Deployment** | Managed cloud | Docker + docker-compose |
### 4.3 UE5 Plugin Layer
| Component | LCKStreaming (Hub) | LCKControl (Companion) |
|-----------|-------------------|----------------------|
| **Communication** | HTTP/JSON-RPC 2.0 | AIDL via JNI |
| **Transport** | HTTPS (cross-network) | Local IPC (same device) |
| **Auth Flow** | Device code (6-digit) + polling | Direct service binding |
| **Token Storage** | EncryptedSharedPreferences | None (companion owns tokens) |
| **RTMP Sinks** | 1 (single destination) | N (multi-destination) |
| **Blocking Model** | Async HTTP callbacks | Synchronous JNI calls |
| **Platform Support** | Cross-platform capable | Android only |
| **Latency** | Network round-trip (100ms+) | IPC (~1ms) |
| **Offline Capable** | No (requires server) | Partial (companion has local cache) |
---
## 5. Streaming Architecture Deep Dive
### 5.1 Hub: Screen Capture + Re-Encoding
```mermaid
graph LR
subgraph "UE5 Game Process"
Render["Game Renderer<br/>(GPU)"]
end
subgraph "Android OS"
FB["Framebuffer /<br/>Display Compositor"]
MP["MediaProjection<br/>(Screen Capture API)"]
end
subgraph "Hub App Process"
VD["VirtualDisplay"]
MC_V["MediaCodec<br/>(H.264 Encoder)"]
MC_A["MediaCodec<br/>(AAC Encoder)"]
AR["AudioRecord<br/>(System Audio)"]
MR["minirtmp<br/>(RTMP Client)"]
end
subgraph "CDN"
RTMP["YouTube / Twitch<br/>RTMP Ingest"]
end
Render --> FB
FB --> MP
MP --> VD
VD --> MC_V
AR --> MC_A
MC_V --> MR
MC_A --> MR
MR --> RTMP
style FB fill:#fbb,stroke:#333
style MP fill:#fbb,stroke:#333
```
**Problems:**
- Extra GPU copy through display compositor
- Re-encoding already rendered frames (quality loss)
- Higher latency (capture → encode → send)
- Higher battery/thermal impact (two encoding passes)
- Captures UI overlays, notifications, system bars
- Resolution limited to display resolution
### 5.2 Control: Direct Render Pipeline Encoding
```mermaid
graph LR
subgraph "UE5 Game Process"
Render["Game Renderer<br/>(GPU)"]
SCC["SceneCaptureComponent2D<br/>(Render Target)"]
ENC["LCK Encoder<br/>(H.264 + AAC)"]
S1["RTMP Sink 1<br/>(YouTube)"]
S2["RTMP Sink 2<br/>(Twitch)"]
S3["RTMP Sink 3<br/>(Custom)"]
end
subgraph "CDN"
YT["YouTube RTMP"]
TW["Twitch RTMP"]
CU["Custom RTMP"]
end
Render --> SCC
SCC --> ENC
ENC --> S1
ENC --> S2
ENC --> S3
S1 --> YT
S2 --> TW
S3 --> CU
style SCC fill:#bfb,stroke:#333
style ENC fill:#bfb,stroke:#333
```
**Advantages:**
- Direct GPU texture access (no compositor overhead)
- Single encode pass (game scene only, no UI clutter)
- Lower latency
- Lower battery/thermal impact
- Configurable resolution independent of display
- Multi-destination from single encode
- Clean game footage (no system overlays)
---
## 6. Feature Comparison Matrix
| Feature | Hub | Control | Notes |
|---------|:---:|:-------:|-------|
| **Meta/Quest Login** | Yes | Yes | Both use Horizon Platform SDK 77.0.1 |
| **YouTube OAuth** | Yes | Yes | Both server-side token exchange |
| **Twitch OAuth** | Yes | Yes | Both server-side token exchange |
| **Multi-Destination Streaming** | No (1) | Yes (N) | Major difference |
| **Stream Plans** | No | Yes | Control has full lifecycle management |
| **Direct Game Encoding** | No | Yes | Control encodes from render pipeline |
| **Screen Capture Streaming** | Yes | No | Hub captures and re-encodes |
| **Custom RTMP Targets** | Yes | Yes | Both support manual RTMP |
| **Game Client Management** | Yes (pairing) | Yes (AIDL) | Different mechanisms |
| **IGDB Game Database** | Yes | No | Hub has game cover art |
| **Watermark** | Yes | No | Hub has overlay support |
| **Subscription Model** | Yes | No | Hub has paid tier |
| **Sentry Crash Reporting** | Yes | No | Hub has telemetry |
| **Certificate Pinning** | Yes | No | Hub has SPKI pinning |
| **Offline Caching** | No | Yes | Control has Room DB |
| **Background Token Refresh** | Unknown | Yes | Control backend has scheduler |
| **CI/CD Pipeline** | Yes (Jenkins) | Partial (deploy.ps1) | Hub has full CI |
| **Desktop Support** | Yes | No | Tauri supports desktop |
| **Cross-Platform** | Yes (Desktop + Android) | No (Android only) | Hub has wider reach |
---
## 7. Pros and Cons
### 7.1 Hub (liv-control-center)
#### Pros
- **Cross-platform**: Tauri supports Desktop + Android, one codebase
- **Self-contained streaming**: No dependency on game integration
- **Works with any game**: Screen capture works regardless of game engine support
- **Production infrastructure**: Jenkins CI/CD, Sentry, cloud backend
- **Rich features**: IGDB, watermarks, subscription model
- **Rust performance**: Memory-safe, low-level control over encoding
#### Cons
- **Screen capture quality**: Re-encoding degrades quality, captures overlays
- **Higher resource usage**: Extra GPU copy + encode pass drains battery faster
- **Single destination**: Can only stream to one platform at a time
- **Complex stack**: Rust + WASM + Tauri + Kotlin JNI is hard to maintain
- **Server dependency**: All communication goes through cloud backend
- **Latency**: Network round-trips for game communication (polling every 2.5s)
- **Low store reviews**: Users experiencing issues (reason for this analysis)
- **Niche UI framework**: Leptos (WASM) has small ecosystem vs Compose
- **No stream plans**: Simple streaming model without plan lifecycle
### 7.2 Control App (lck-control)
#### Pros
- **Direct render pipeline**: Game encodes from GPU, best possible quality
- **Multi-destination**: Stream to YouTube + Twitch + custom simultaneously
- **Low latency IPC**: AIDL communication in ~1ms vs 100ms+ network calls
- **Stream plans**: Full lifecycle (DRAFT → READY → LIVE → ENDED)
- **Clean architecture**: Standard Android stack (Compose, Hilt, Room, Retrofit)
- **Own backend**: Full control over API, auth, token management
- **SDK module**: Clean AAR for UE5 consumption via JNI
- **Lower resource usage**: No screen capture or re-encoding overhead
- **Maintainable**: Kotlin + Compose is mainstream Android with large ecosystem
- **Offline caching**: Room DB + encrypted token store
#### Cons
- **Android only**: No desktop support
- **Requires game integration**: Game must use LCKControl plugin (not universal)
- **Prototype stage**: Not production-ready yet
- **Self-hosted backend**: Requires infrastructure management (Docker on NAS)
- **No CI/CD**: Manual builds via PowerShell script
- **No crash reporting**: No Sentry or equivalent
- **No subscription model**: No monetization built in
- **No IGDB integration**: No game metadata/artwork
- **Blocking IPC**: Synchronous JNI calls could cause ANRs if slow
---
## 8. UE5 Plugin Comparison
### 8.1 LCKStreaming Plugin (uses Hub)
```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> LoggingIn: StartLogin()
LoggingIn --> WaitingForCode: create_device_login_attempt
WaitingForCode --> Polling: Display 6-digit code
Polling --> Authenticated: check_device_login_attempt<br/>(every 2.5s)
Polling --> Polling: Not yet paired
Authenticated --> FetchingProfile: get_user_profile
FetchingProfile --> Ready: Got RTMP target
Ready --> Streaming: StartStreaming()
Streaming --> Ready: StopStreaming()
Ready --> Idle: Logout()
note right of Polling
User must manually enter
code in Hub app or website
end note
```
**Architecture:**
- `ULCKStreamingSubsystem` — GameInstance subsystem, owns API client + RTMP sink
- `FLCKStreamingApiClient` — HTTP client, JSON-RPC 2.0, cert pinning
- `FLCKRtmpSink` / `FLCKRtmpClient` — Single RTMP connection via librtmp
- Auth token stored in platform credential store
- Single streaming target resolved by backend
### 8.2 LCKControl Plugin (uses Companion App)
```mermaid
stateDiagram-v2
[*] --> Disconnected
Disconnected --> Connecting: ConnectToCompanionApp()
Connecting --> Connected: AIDL service bound<br/>(poll every 1s)
Connected --> HasPlans: GetStreamPlans()
HasPlans --> Prepared: PrepareStreamPlan(planId)<br/>→ RTMP URLs resolved
Prepared --> Streaming: StartStreamPlan(planId)<br/>+ Attach N RTMP sinks
Streaming --> Prepared: EndStreamPlan(planId)
Connected --> Disconnected: DisconnectFromCompanionApp()
note right of Connected
Direct AIDL binding,
no pairing code needed
end note
note right of Streaming
Multiple RTMP sinks active
simultaneously
end note
```
**Architecture:**
- `ULCKControlSubsystem` — GameInstance subsystem, owns JNI bridge + multiple RTMP sinks
- `LCKControlAndroid.cpp` — ~700 lines of JNI bindings to `LckControlClient` (AAR)
- Multiple `FLCKRtmpSink` instances — one per stream destination
- No token management — companion app handles all auth
- Full stream plan lifecycle control
### 8.3 Shared Infrastructure (LCK Base Plugin)
Both plugins share:
- `ILCKStreamingFeature` — Common interface (StartLogin, StartStreaming, StopStreaming, etc.)
- `ILCKEncoderFactory` — Encoder creation
- `ULCKRecorderSubsystem` — Encoder lifecycle management
- `FLCKRtmpSink` / `FLCKRtmpClient` — RTMP transport layer
- H.264 + AAC encoding via platform-specific backends (NVCodec, MediaCodec)
---
## 9. Backend Comparison
### 9.1 Hub Backend (`api.obi.gg`)
```mermaid
graph TB
subgraph "Cloud (Managed)"
API_H["Hub Backend API"]
DB_H["Database<br/>(Unknown)"]
IGDB["IGDB API<br/>(Game Metadata)"]
end
Hub["Hub App"] -->|"JSON-RPC 2.0<br/>POST /api/rpc"| API_H
Game_S["LCKStreaming<br/>Plugin"] -->|"JSON-RPC 2.0<br/>POST /api/rpc"| API_H
API_H --> DB_H
API_H --> IGDB
style API_H fill:#f96,stroke:#333
```
**Known RPC Methods:**
- `LoginUser`, `RefreshUser` — Auth
- `ListMyStreamingTargets`, `CreateStreamingTarget`, `UpdateStreamingTarget`, `DeleteStreamingTarget` — Targets
- `PairDevice`, `UnpairDevice`, `GetConnectedGames` — Device management
- `StartStreaming`, `StopStreaming` — Stream events
- `SearchIgdbGames` — Game metadata
- `CreateOauthConnectIntent`, `GetOauthConnectIntent` — OAuth
### 9.2 Control Backend (`lck-control-backend`)
```mermaid
graph TB
subgraph "Self-Hosted (Docker on NAS)"
API_C["Fastify 5 API<br/>(TypeScript)"]
Prisma["Prisma 6.4 ORM"]
SQLite["SQLite DB"]
Scheduler["Token Refresh<br/>Scheduler (10min)"]
end
App["Control App"] -->|"REST API<br/>JWT Bearer Auth"| API_C
API_C --> Prisma --> SQLite
Scheduler --> API_C
API_C -->|OAuth| Google["Google OAuth"]
API_C -->|OAuth| Twitch_API["Twitch OAuth"]
API_C -->|Nonce Validate| Meta_Graph["Meta Graph API"]
API_C -->|Live API| YT_API["YouTube Live API"]
API_C -->|Helix API| TW_API["Twitch Helix API"]
style API_C fill:#9cf,stroke:#333
```
**REST Endpoints:**
| Group | Endpoints |
|-------|-----------|
| Auth | `POST /auth/meta/callback`, `POST /auth/refresh`, `GET /auth/me`, `POST /auth/logout` |
| Providers | `GET /providers/accounts`, `GET /providers/{yt\|tw}/auth-url`, `POST /providers/{yt\|tw}/callback`, `DELETE /providers/:serviceId` |
| Streams | `GET /streams/plans`, `POST /streams/plans`, `GET /streams/plans/:id`, `DELETE /streams/plans/:id` |
| Lifecycle | `POST /streams/plans/:id/prepare`, `POST /streams/plans/:id/start`, `POST /streams/plans/:id/end` |
---
## 10. Data Flow Comparison
### 10.1 Hub: Centralized Server Model
```mermaid
graph LR
subgraph "Data Ownership"
direction TB
Server_H["Hub Backend<br/>(owns ALL data)"]
end
Hub_App["Hub App<br/>(thin client)"] <-->|"All state via<br/>JSON-RPC"| Server_H
Game_H["UE5 Game<br/>(paired device)"] <-->|"All state via<br/>JSON-RPC"| Server_H
YT_H["YouTube API"] <--> Server_H
TW_H["Twitch API"] <--> Server_H
style Server_H fill:#f96,stroke:#333
```
- **Single source of truth**: Backend server
- **No local cache**: App relies on network for all state
- **Game is decoupled**: Only communicates with server, never with app
- **Offline = broken**: Cannot function without server connectivity
### 10.2 Control: Distributed Ownership Model
```mermaid
graph LR
subgraph "Data Ownership"
direction TB
Server_C["Control Backend<br/>(tokens, plans,<br/>OAuth)"]
App_C["Control App<br/>(local cache,<br/>session tokens)"]
Game_C["UE5 Game<br/>(RTMP streams)"]
end
App_C <-->|REST API| Server_C
Game_C <-->|"AIDL IPC<br/>(stream plans,<br/>RTMP config)"| App_C
Server_C <--> YT_C["YouTube API"]
Server_C <--> TW_C["Twitch API"]
Game_C -->|"RTMP<br/>(direct)"| CDN["YouTube / Twitch<br/>RTMP Ingest"]
style App_C fill:#9f9,stroke:#333
style Game_C fill:#9cf,stroke:#333
```
- **Distributed state**: Backend (tokens, plans), App (cache, session), Game (streams)
- **Local caching**: Room DB provides offline access to plans and accounts
- **Game is tightly coupled**: Direct IPC with companion app
- **Partial offline**: Can view cached plans without network
---
## 11. Unification Strategy
### 11.1 Recommended Direction: Evolve Control App into Production
The Control architecture is fundamentally superior for game streaming because:
1. **Direct encode > screen capture** — Quality, performance, and battery life
2. **Multi-destination > single target** — Key user-facing feature
3. **IPC > server polling** — Reliability and responsiveness
4. **Stream plans > ad-hoc streaming** — Better UX for recurring setups
5. **Standard Android stack > Rust/WASM** — Easier maintenance and hiring
### 11.2 Migration Roadmap
```mermaid
gantt
title Unification Roadmap
dateFormat YYYY-MM-DD
axisFormat %b %Y
section Phase 1: Production Readiness
CI/CD pipeline (Jenkins/GH Actions) :p1a, 2026-03-01, 14d
Sentry crash reporting :p1b, 2026-03-01, 7d
Backend deploy to cloud :p1c, 2026-03-08, 7d
Certificate pinning (OkHttp) :p1d, 2026-03-08, 3d
section Phase 2: Feature Parity
IGDB game metadata integration :p2a, 2026-03-15, 7d
Watermark / overlay support in encoder :p2b, 2026-03-15, 10d
Subscription model + paywall :p2c, 2026-03-22, 14d
section Phase 3: Hub Migration
Add fallback screen-capture mode :p3a, 2026-04-05, 14d
Port device pairing (for non-integrated games) :p3b, 2026-04-05, 10d
Migrate Hub users to Control :p3c, 2026-04-19, 14d
Deprecate Hub app :p3d, 2026-05-03, 7d
section Phase 4: Polish
Desktop companion (optional) :p4a, 2026-05-10, 21d
Advanced stream analytics :p4b, 2026-05-10, 14d
Store listing + marketing :p4c, 2026-05-24, 7d
```
### 11.3 What to Keep from Each
```mermaid
graph TB
subgraph "Unified App"
direction TB
A["Control App Architecture<br/>(Kotlin + Compose + Hilt)"]
B["Control Backend<br/>(Fastify + Prisma + SQLite)"]
C["LCKControl Plugin<br/>(AIDL + multi-destination)"]
D["Stream Plan System<br/>(DRAFT → READY → LIVE → ENDED)"]
end
subgraph "Adopt from Hub"
E["Sentry Crash Reporting"]
F["IGDB Game Database"]
G["Certificate Pinning"]
H["Jenkins CI/CD"]
I["Watermark Renderer"]
J["Screen Capture Fallback"]
end
subgraph "Discard"
K["Rust/Tauri/Leptos Stack"]
L["JSON-RPC 2.0 Protocol"]
M["minirtmp (Rust RTMP)"]
N["Device Code Pairing<br/>(replaced by AIDL)"]
O["Single-Destination Limit"]
end
E --> A
F --> B
G --> A
H --> A
I --> C
J --> A
style A fill:#9f9,stroke:#333
style B fill:#9cf,stroke:#333
style C fill:#9f9,stroke:#333
style D fill:#9f9,stroke:#333
style K fill:#fbb,stroke:#333
style L fill:#fbb,stroke:#333
style M fill:#fbb,stroke:#333
style N fill:#fbb,stroke:#333
style O fill:#fbb,stroke:#333
```
### 11.4 Hybrid Mode: Screen Capture Fallback
To maintain the Hub's "works with any game" advantage, add a fallback path:
```mermaid
graph TB
Start["Game Launches"] --> Check{"LCKControl Plugin<br/>integrated?"}
Check -->|Yes| AIDL["AIDL IPC Path<br/>(direct encode,<br/>multi-destination)"]
Check -->|No| Capture["Screen Capture Path<br/>(MediaProjection,<br/>single destination)"]
AIDL --> Stream["Stream to Platforms"]
Capture --> Stream
style AIDL fill:#9f9,stroke:#333
style Capture fill:#ff9,stroke:#333
```
This gives the unified app both modes:
- **Primary**: Direct encoding via AIDL (high quality, multi-destination)
- **Fallback**: Screen capture for games without plugin integration (compatibility)
---
## 12. Risk Assessment
| Risk | Impact | Mitigation |
|------|--------|------------|
| Hub users lose access during migration | High | Run both apps in parallel during transition, provide migration guide |
| AIDL only works on Android (no desktop) | Medium | Screen capture fallback for desktop; evaluate PCVR needs later |
| Self-hosted backend scalability | Medium | Move to managed cloud (Railway, Fly.io) before store launch |
| Synchronous JNI blocking causes ANR | Medium | Add timeout handling, move to async callback pattern |
| No subscription model in Control | Low | Implement before store launch using existing Hub billing logic |
| Losing crash telemetry | Low | Add Sentry SDK early in Phase 1 |
---
## 13. Summary Decision Matrix
```mermaid
quadrantChart
title Streaming Quality vs Maintenance Complexity
x-axis Low Maintenance --> High Maintenance
y-axis Low Quality --> High Quality
quadrant-1 Ideal
quadrant-2 Overengineered
quadrant-3 Avoid
quadrant-4 Quick & Dirty
Control App: [0.35, 0.85]
Hub App: [0.75, 0.45]
Unified - Recommended: [0.45, 0.90]
```
**Recommendation**: The Control App architecture with adopted Hub features provides the best path forward — higher streaming quality with a more maintainable stack. The Hub's Rust/Tauri/Leptos stack adds significant complexity without proportional benefits for an Android-focused product.
---
*Document generated 2026-02-26. Based on analysis of `liv-control-center`, `lck-control`, `lck-control-backend`, and `LCKGame` codebases.*

Binary file not shown.

1063
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "lck-control",
"version": "1.0.0",
"main": "index.js",
"directories": {
"doc": "docs"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"node-media-server": "^4.2.4"
}
}

View File

@@ -4,10 +4,14 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.hardware.HardwareBuffer
import android.os.IBinder
import android.os.ParcelFileDescriptor
import com.omixlab.lckcontrol.shared.ConnectedClientInfo
import com.omixlab.lckcontrol.shared.ILckControlCallback
import com.omixlab.lckcontrol.shared.ILckControlService
import com.omixlab.lckcontrol.shared.ILckStreamingCallback
import com.omixlab.lckcontrol.shared.ILckStreamingService
import com.omixlab.lckcontrol.shared.LinkedAccount
import com.omixlab.lckcontrol.shared.StreamPlan
import com.omixlab.lckcontrol.shared.StreamPlanConfig
@@ -21,9 +25,11 @@ class LckControlClient(private val context: Context) {
private const val SERVICE_PACKAGE = "com.omixlab.lckcontrol"
private const val SERVICE_CLASS = "$SERVICE_PACKAGE.service.LckControlService"
private const val PERMISSION = "$SERVICE_PACKAGE.permission.USE_LCK_CONTROL"
private const val ACTION_BIND_STREAMING = "$SERVICE_PACKAGE.BIND_STREAMING"
}
private var service: ILckControlService? = null
private var streamingService: ILckStreamingService? = null
private var clientId: String? = null
private val _connected = MutableStateFlow(false)
@@ -35,6 +41,12 @@ class LckControlClient(private val context: Context) {
private val _streamPlans = MutableStateFlow<List<StreamPlan>>(emptyList())
val streamPlans: StateFlow<List<StreamPlan>> = _streamPlans.asStateFlow()
private val _streamingState = MutableStateFlow("IDLE")
val streamingState: StateFlow<String> = _streamingState.asStateFlow()
private val _streamingConnected = MutableStateFlow(false)
val streamingConnected: StateFlow<Boolean> = _streamingConnected.asStateFlow()
private val callback = object : ILckControlCallback.Stub() {
override fun onStreamPlansChanged(plans: List<StreamPlan>) {
_streamPlans.value = plans
@@ -54,6 +66,33 @@ class LckControlClient(private val context: Context) {
}
}
private val streamingCallback = object : ILckStreamingCallback.Stub() {
override fun onBufferReleased(bufferIndex: Int) {
onBufferReleasedListener?.invoke(bufferIndex)
}
override fun onStreamingStateChanged(state: String) {
_streamingState.value = state
}
override fun onStreamingError(code: Int, message: String) {
onStreamingErrorListener?.invoke(code, message)
}
override fun onStreamingStats(videoBitrate: Long, audioBitrate: Long, fps: Int, droppedFrames: Int) {
onStreamingStatsListener?.invoke(videoBitrate, audioBitrate, fps, droppedFrames)
}
}
/** Listener for buffer release events (game can reuse the buffer). */
var onBufferReleasedListener: ((Int) -> Unit)? = null
/** Listener for streaming errors. */
var onStreamingErrorListener: ((Int, String) -> Unit)? = null
/** Listener for streaming stats updates. */
var onStreamingStatsListener: ((Long, Long, Int, Int) -> Unit)? = null
private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
service = ILckControlService.Stub.asInterface(binder)
@@ -70,6 +109,20 @@ class LckControlClient(private val context: Context) {
}
}
private val streamingConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
streamingService = ILckStreamingService.Stub.asInterface(binder)
streamingService?.registerStreamingCallback(streamingCallback)
_streamingConnected.value = true
}
override fun onServiceDisconnected(name: ComponentName?) {
streamingService = null
_streamingConnected.value = false
_streamingState.value = "IDLE"
}
}
fun bind(): Boolean {
val intent = Intent().apply {
component = ComponentName(SERVICE_PACKAGE, SERVICE_CLASS)
@@ -93,6 +146,51 @@ class LckControlClient(private val context: Context) {
_authenticated.value = false
}
// ── Streaming service ────────────────────────────────
fun bindStreaming(): Boolean {
val intent = Intent(ACTION_BIND_STREAMING).apply {
component = ComponentName(SERVICE_PACKAGE, SERVICE_CLASS)
}
return context.bindService(intent, streamingConnection, Context.BIND_AUTO_CREATE)
}
fun unbindStreaming() {
streamingService?.let { svc ->
svc.unregisterStreamingCallback(streamingCallback)
}
try {
context.unbindService(streamingConnection)
} catch (_: IllegalArgumentException) {}
streamingService = null
_streamingConnected.value = false
_streamingState.value = "IDLE"
}
// ── Texture pool ─────────────────────────────────────
fun registerTexturePool(buffers: Array<HardwareBuffer>, width: Int, height: Int, format: Int) {
streamingService?.registerTexturePool(buffers, width, height, format)
}
fun unregisterTexturePool() {
streamingService?.unregisterTexturePool()
}
// ── Frame submission (called from game render thread) ──
fun submitVideoFrame(bufferIndex: Int, timestampNs: Long, gpuFenceFd: ParcelFileDescriptor?) {
streamingService?.submitVideoFrame(bufferIndex, timestampNs, gpuFenceFd)
}
fun submitAudioFrame(pcmData: ByteArray, timestampNs: Long, sampleRate: Int, channels: Int, bitsPerSample: Int) {
streamingService?.submitAudioFrame(pcmData, timestampNs, sampleRate, channels, bitsPerSample)
}
fun isStreaming(): Boolean {
return streamingService?.isStreaming ?: false
}
// ── Auth ────────────────────────────────────────────
fun isAuthenticated(): Boolean {

View File

@@ -0,0 +1,8 @@
package com.omixlab.lckcontrol.shared;
interface ILckStreamingCallback {
oneway void onBufferReleased(int bufferIndex);
oneway void onStreamingStateChanged(String state);
oneway void onStreamingError(int code, String message);
oneway void onStreamingStats(long videoBitrate, long audioBitrate, int fps, int droppedFrames);
}

View File

@@ -0,0 +1,22 @@
package com.omixlab.lckcontrol.shared;
import android.hardware.HardwareBuffer;
import android.os.ParcelFileDescriptor;
import com.omixlab.lckcontrol.shared.ILckStreamingCallback;
interface ILckStreamingService {
// Texture pool (game allocates, app receives)
void registerTexturePool(in HardwareBuffer[] buffers, int width, int height, int format);
void unregisterTexturePool();
// Frame submission (game -> app, one-way for performance)
oneway void submitVideoFrame(int bufferIndex, long timestampNs, in ParcelFileDescriptor gpuFence);
oneway void submitAudioFrame(in byte[] pcmData, long timestampNs, int sampleRate, int channels, int bitsPerSample);
// Streaming lifecycle
boolean isStreaming();
// Callbacks
void registerStreamingCallback(ILckStreamingCallback callback);
void unregisterStreamingCallback(ILckStreamingCallback callback);
}

View File

@@ -0,0 +1,3 @@
package com.omixlab.lckcontrol.shared;
parcelable StreamingConfig;

View File

@@ -10,6 +10,9 @@ data class LinkedAccount(
val accountId: String,
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(
@@ -19,6 +22,9 @@ data class LinkedAccount(
accountId = parcel.readString()!!,
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) {
@@ -28,6 +34,9 @@ data class LinkedAccount(
parcel.writeString(accountId)
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

Some files were not shown because too many files have changed in this diff Show More