Compare commits
10 Commits
1480a2944b
...
8fd9f5815a
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fd9f5815a | |||
| b235eabd40 | |||
| ec1b84994b | |||
| d44fe488bd | |||
| 8b9c18637a | |||
| 870139b054 | |||
| ef221ca132 | |||
| c632e22033 | |||
| c1ff5351b7 | |||
| 097cd24ea9 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,3 +22,6 @@ ovr-platform-util.exe
|
||||
# Build counter
|
||||
.buildcount
|
||||
/.claude
|
||||
|
||||
# Tools
|
||||
node_modules/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
58
app/src/main/cpp/CMakeLists.txt
Normal file
58
app/src/main/cpp/CMakeLists.txt
Normal 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
|
||||
)
|
||||
313
app/src/main/cpp/clip_recorder.cpp
Normal file
313
app/src/main/cpp/clip_recorder.cpp
Normal 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);
|
||||
}
|
||||
75
app/src/main/cpp/clip_recorder.h
Normal file
75
app/src/main/cpp/clip_recorder.h
Normal 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
|
||||
};
|
||||
435
app/src/main/cpp/composition_pipeline.cpp
Normal file
435
app/src/main/cpp/composition_pipeline.cpp
Normal 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");
|
||||
}
|
||||
108
app/src/main/cpp/composition_pipeline.h
Normal file
108
app/src/main/cpp/composition_pipeline.h
Normal file
@@ -0,0 +1,108 @@
|
||||
#pragma once
|
||||
|
||||
#include <GLES3/gl3.h>
|
||||
#include <GLES2/gl2ext.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct CompositionTransform {
|
||||
float posX = 0.0f; // NDC [-1, 1]
|
||||
float posY = 0.0f;
|
||||
float scaleX = 1.0f;
|
||||
float scaleY = 1.0f;
|
||||
float rotation = 0.0f; // radians
|
||||
float anchorX = 0.5f; // [0, 1] within layer
|
||||
float anchorY = 0.5f;
|
||||
};
|
||||
|
||||
struct CompositionLayer {
|
||||
int id = -1;
|
||||
GLuint textureId = 0; // GL_TEXTURE_2D
|
||||
int texWidth = 0;
|
||||
int texHeight = 0;
|
||||
CompositionTransform transform;
|
||||
float opacity = 1.0f;
|
||||
bool enabled = true;
|
||||
int zOrder = 0;
|
||||
std::string tag;
|
||||
bool ownsTexture = true; // if true, pipeline deletes texture on removal
|
||||
};
|
||||
|
||||
/**
|
||||
* GPU composition pipeline.
|
||||
* Renders a base OES texture (game frame) plus overlay layers to an FBO,
|
||||
* producing a GL_TEXTURE_2D that can be blit to encoder and preview surfaces.
|
||||
*/
|
||||
class CompositionPipeline {
|
||||
public:
|
||||
CompositionPipeline();
|
||||
~CompositionPipeline();
|
||||
|
||||
/** Initialize FBO, shaders, and quad geometry at encoder resolution. */
|
||||
bool Init(int width, int height);
|
||||
|
||||
/**
|
||||
* Compose the scene: render base OES texture + overlay layers to FBO.
|
||||
* Must be called on the GL thread.
|
||||
*/
|
||||
void Compose(GLuint srcOesTexture);
|
||||
|
||||
/** Returns the FBO color attachment (GL_TEXTURE_2D). */
|
||||
GLuint GetComposedTexture() const { return fboTexture; }
|
||||
|
||||
/** Layer management — thread-safe. */
|
||||
int AddLayer(GLuint textureId, int texW, int texH,
|
||||
const CompositionTransform& transform,
|
||||
float opacity, int zOrder, const std::string& tag,
|
||||
bool ownsTexture = true);
|
||||
void RemoveLayer(int layerId);
|
||||
void UpdateLayerTransform(int layerId, const CompositionTransform& transform);
|
||||
void UpdateLayerOpacity(int layerId, float opacity);
|
||||
void SetLayerEnabled(int layerId, bool enabled);
|
||||
|
||||
/** Upload raw RGBA pixels as a GL_TEXTURE_2D. Returns texture ID (0 on failure). */
|
||||
static GLuint UploadTexture(const uint8_t* rgbaData, int w, int h);
|
||||
|
||||
/** Release all GL resources. Must be called on the GL thread. */
|
||||
void Release();
|
||||
|
||||
bool IsInitialized() const { return initialized; }
|
||||
|
||||
private:
|
||||
void RenderBasePass(GLuint srcOesTexture);
|
||||
void RenderStandbyPattern();
|
||||
void RenderOverlayLayers();
|
||||
void BuildTransformMatrix(const CompositionLayer& layer, float* outMat4);
|
||||
|
||||
// Standby pattern shader
|
||||
GLuint standbyProgram = 0;
|
||||
|
||||
int fboWidth = 0;
|
||||
int fboHeight = 0;
|
||||
bool initialized = false;
|
||||
|
||||
// FBO
|
||||
GLuint fbo = 0;
|
||||
GLuint fboTexture = 0; // RGBA8 color attachment
|
||||
|
||||
// Shaders
|
||||
GLuint baseProgram = 0; // samplerExternalOES for game frame
|
||||
GLuint overlayProgram = 0; // sampler2D + uMVP + uOpacity for layers
|
||||
|
||||
// Shared quad VAO
|
||||
GLuint quadVao = 0;
|
||||
GLuint quadVbo = 0;
|
||||
|
||||
// Overlay uniform locations
|
||||
GLint overlayMvpLoc = -1;
|
||||
GLint overlayOpacityLoc = -1;
|
||||
GLint overlayTexLoc = -1;
|
||||
|
||||
// Layers (protected by mutex)
|
||||
std::mutex layerMutex;
|
||||
std::vector<CompositionLayer> layers;
|
||||
int nextLayerId = 1;
|
||||
};
|
||||
328
app/src/main/cpp/cortex_recorder.cpp
Normal file
328
app/src/main/cpp/cortex_recorder.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
69
app/src/main/cpp/cortex_recorder.h
Normal file
69
app/src/main/cpp/cortex_recorder.h
Normal 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;
|
||||
};
|
||||
269
app/src/main/cpp/egl_context.cpp
Normal file
269
app/src/main/cpp/egl_context.cpp
Normal 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");
|
||||
}
|
||||
94
app/src/main/cpp/egl_context.h
Normal file
94
app/src/main/cpp/egl_context.h
Normal 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();
|
||||
};
|
||||
235
app/src/main/cpp/faststart.cpp
Normal file
235
app/src/main/cpp/faststart.cpp
Normal 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;
|
||||
}
|
||||
12
app/src/main/cpp/faststart.h
Normal file
12
app/src/main/cpp/faststart.h
Normal 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);
|
||||
348
app/src/main/cpp/jni_bridge.cpp
Normal file
348
app/src/main/cpp/jni_bridge.cpp
Normal 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"
|
||||
177
app/src/main/cpp/rtmp_client.cpp
Normal file
177
app/src/main/cpp/rtmp_client.cpp
Normal 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);
|
||||
}
|
||||
34
app/src/main/cpp/rtmp_client.h
Normal file
34
app/src/main/cpp/rtmp_client.h
Normal 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;
|
||||
};
|
||||
278
app/src/main/cpp/rtmp_sink.cpp
Normal file
278
app/src/main/cpp/rtmp_sink.cpp
Normal 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;
|
||||
}
|
||||
43
app/src/main/cpp/rtmp_sink.h
Normal file
43
app/src/main/cpp/rtmp_sink.h
Normal 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;
|
||||
};
|
||||
998
app/src/main/cpp/streaming_engine.cpp
Normal file
998
app/src/main/cpp/streaming_engine.cpp
Normal 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);
|
||||
}
|
||||
248
app/src/main/cpp/streaming_engine.h
Normal file
248
app/src/main/cpp/streaming_engine.h
Normal 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;
|
||||
};
|
||||
164
app/src/main/cpp/third_party/librtmp/include/librtmp/amf.h
vendored
Normal file
164
app/src/main/cpp/third_party/librtmp/include/librtmp/amf.h
vendored
Normal 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__ */
|
||||
91
app/src/main/cpp/third_party/librtmp/include/librtmp/bytes.h
vendored
Normal file
91
app/src/main/cpp/third_party/librtmp/include/librtmp/bytes.h
vendored
Normal 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
|
||||
|
||||
402
app/src/main/cpp/third_party/librtmp/include/librtmp/dh.h
vendored
Normal file
402
app/src/main/cpp/third_party/librtmp/include/librtmp/dh.h
vendored
Normal 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;
|
||||
}
|
||||
199
app/src/main/cpp/third_party/librtmp/include/librtmp/dhgroups.h
vendored
Normal file
199
app/src/main/cpp/third_party/librtmp/include/librtmp/dhgroups.h
vendored
Normal 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"
|
||||
|
||||
1426
app/src/main/cpp/third_party/librtmp/include/librtmp/handshake.h
vendored
Normal file
1426
app/src/main/cpp/third_party/librtmp/include/librtmp/handshake.h
vendored
Normal file
File diff suppressed because it is too large
Load Diff
47
app/src/main/cpp/third_party/librtmp/include/librtmp/http.h
vendored
Normal file
47
app/src/main/cpp/third_party/librtmp/include/librtmp/http.h
vendored
Normal 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
|
||||
69
app/src/main/cpp/third_party/librtmp/include/librtmp/log.h
vendored
Normal file
69
app/src/main/cpp/third_party/librtmp/include/librtmp/log.h
vendored
Normal 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
|
||||
378
app/src/main/cpp/third_party/librtmp/include/librtmp/rtmp.h
vendored
Normal file
378
app/src/main/cpp/third_party/librtmp/include/librtmp/rtmp.h
vendored
Normal 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
|
||||
141
app/src/main/cpp/third_party/librtmp/include/librtmp/rtmp_sys.h
vendored
Normal file
141
app/src/main/cpp/third_party/librtmp/include/librtmp/rtmp_sys.h
vendored
Normal 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
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
337
app/src/main/java/com/omixlab/lckcontrol/cortex/CortexManager.kt
Normal file
337
app/src/main/java/com/omixlab/lckcontrol/cortex/CortexManager.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() },
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"}"""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
274
app/src/main/java/com/omixlab/lckcontrol/ui/chat/ChatScreen.kt
Normal file
274
app/src/main/java/com/omixlab/lckcontrol/ui/chat/ChatScreen.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
BIN
app/src/main/jniLibs/arm64-v8a/libcrypto.so
Normal file
BIN
app/src/main/jniLibs/arm64-v8a/libcrypto.so
Normal file
Binary file not shown.
BIN
app/src/main/jniLibs/arm64-v8a/librtmp.so
Normal file
BIN
app/src/main/jniLibs/arm64-v8a/librtmp.so
Normal file
Binary file not shown.
BIN
app/src/main/jniLibs/arm64-v8a/libssl.so
Normal file
BIN
app/src/main/jniLibs/arm64-v8a/libssl.so
Normal file
Binary file not shown.
BIN
app/src/main/jniLibs/arm64-v8a/libz.so
Normal file
BIN
app/src/main/jniLibs/arm64-v8a/libz.so
Normal file
Binary file not shown.
BIN
app/src/main/res/raw/lck_lan.p12
Normal file
BIN
app/src/main/res/raw/lck_lan.p12
Normal file
Binary file not shown.
7236
docs/hub-vs-control-comparison.html
Normal file
7236
docs/hub-vs-control-comparison.html
Normal file
File diff suppressed because one or more lines are too long
745
docs/hub-vs-control-comparison.md
Normal file
745
docs/hub-vs-control-comparison.md
Normal 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.*
|
||||
BIN
docs/hub-vs-control-comparison.pdf
Normal file
BIN
docs/hub-vs-control-comparison.pdf
Normal file
Binary file not shown.
1063
package-lock.json
generated
Normal file
1063
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "lck-control",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"directories": {
|
||||
"doc": "docs"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"node-media-server": "^4.2.4"
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.omixlab.lckcontrol.shared;
|
||||
|
||||
parcelable StreamingConfig;
|
||||
@@ -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
Reference in New Issue
Block a user