diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index d81b055..03d9229 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -154,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)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c91af52..106ab52 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,9 @@
+
+
+
@@ -82,6 +85,14 @@
+
+
+
+
+
+
diff --git a/app/src/main/cpp/jni_bridge.cpp b/app/src/main/cpp/jni_bridge.cpp
index 5b3f8be..f32ffb4 100644
--- a/app/src/main/cpp/jni_bridge.cpp
+++ b/app/src/main/cpp/jni_bridge.cpp
@@ -17,6 +17,7 @@ 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;
@@ -47,6 +48,24 @@ Java_com_omixlab_lckcontrol_streaming_NativeStreamingEngine_nativeCreate(
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(&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(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,
diff --git a/app/src/main/cpp/streaming_engine.cpp b/app/src/main/cpp/streaming_engine.cpp
index 180b10b..027443d 100644
--- a/app/src/main/cpp/streaming_engine.cpp
+++ b/app/src/main/cpp/streaming_engine.cpp
@@ -628,6 +628,12 @@ void StreamingEngine::DrainVideoEncoder() {
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 lock(statsMutex);
statsVideoBytes += info.size;
statsFrameCount++;
@@ -986,3 +992,7 @@ void StreamingEngine::DisableCortexRecording() {
void StreamingEngine::SetCortexSegmentCallback(CortexRecorder::SegmentCallback cb) {
cortexRecorder.SetSegmentCallback(std::move(cb));
}
+
+void StreamingEngine::SetEncodedFrameCallback(EncodedFrameCallback callback) {
+ encodedFrameCallback = std::move(callback);
+}
diff --git a/app/src/main/cpp/streaming_engine.h b/app/src/main/cpp/streaming_engine.h
index 6a3cb85..24e05c2 100644
--- a/app/src/main/cpp/streaming_engine.h
+++ b/app/src/main/cpp/streaming_engine.h
@@ -50,6 +50,7 @@ public:
using StatsCallback = std::function;
using ErrorCallback = std::function;
using BufferReleasedCallback = std::function;
+ using EncodedFrameCallback = std::function;
StreamingEngine();
~StreamingEngine();
@@ -106,6 +107,9 @@ public:
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();
@@ -227,6 +231,7 @@ private:
StatsCallback statsCallback;
ErrorCallback errorCallback;
BufferReleasedCallback bufferReleasedCallback;
+ EncodedFrameCallback encodedFrameCallback;
bool InitVideoEncoder();
bool InitAudioEncoder();
diff --git a/app/src/main/java/com/omixlab/lckcontrol/LckControlApp.kt b/app/src/main/java/com/omixlab/lckcontrol/LckControlApp.kt
index ae9327f..f5b9f4d 100644
--- a/app/src/main/java/com/omixlab/lckcontrol/LckControlApp.kt
+++ b/app/src/main/java/com/omixlab/lckcontrol/LckControlApp.kt
@@ -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))
+ }
+}
diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt b/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt
index 182caa6..54951bb 100644
--- a/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt
+++ b/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt
@@ -1,5 +1,6 @@
package com.omixlab.lckcontrol.data.remote
+import okhttp3.MultipartBody
import retrofit2.http.*
interface LckApiService {
@@ -80,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,
+ )
}
diff --git a/app/src/main/java/com/omixlab/lckcontrol/p2p/PeerSessionManager.kt b/app/src/main/java/com/omixlab/lckcontrol/p2p/PeerSessionManager.kt
new file mode 100644
index 0000000..66ee449
--- /dev/null
+++ b/app/src/main/java/com/omixlab/lckcontrol/p2p/PeerSessionManager.kt
@@ -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()
+ 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()
+ }
+}
diff --git a/app/src/main/java/com/omixlab/lckcontrol/p2p/channels/ControlChannelHandler.kt b/app/src/main/java/com/omixlab/lckcontrol/p2p/channels/ControlChannelHandler.kt
new file mode 100644
index 0000000..584c3c1
--- /dev/null
+++ b/app/src/main/java/com/omixlab/lckcontrol/p2p/channels/ControlChannelHandler.kt
@@ -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