diff --git a/app/src/main/assets/web/index.html b/app/src/main/assets/web/index.html
index 5529067..611417f 100644
--- a/app/src/main/assets/web/index.html
+++ b/app/src/main/assets/web/index.html
@@ -214,6 +214,30 @@
margin-right: 12px;
object-fit: contain;
}
+ .split-category-section {
+ margin-bottom: 16px;
+ }
+ .split-category-header {
+ display: flex;
+ align-items: center;
+ padding: 4px 4px 8px 4px;
+ }
+ .split-category-bar {
+ width: 4px;
+ height: 18px;
+ border-radius: 2px;
+ margin-right: 10px;
+ }
+ .split-category-title {
+ font-size: 15px;
+ font-weight: bold;
+ color: #ddd;
+ }
+ .split-category-items {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
/* ── Bubble Composer ── */
#input-bubble {
diff --git a/app/src/main/assets/web/js/fallback.js b/app/src/main/assets/web/js/fallback.js
index a1ceb86..6b0fca9 100644
--- a/app/src/main/assets/web/js/fallback.js
+++ b/app/src/main/assets/web/js/fallback.js
@@ -50,6 +50,12 @@ class FallbackDecoder {
if (view.length < 9) return;
if (view[0] === 0x02) return; // skip SPS/PPS config (not relevant for MJPEG)
+ // Silently drop non-JPEG payloads. During the startup window, the server
+ // may briefly emit H.264 frames before it processes the client's
+ // `codec: mjpeg` control message — those frames would otherwise raise
+ // `InvalidStateError` in createImageBitmap and spam the console.
+ if (view[8] !== 0xFF || view[9] !== 0xD8) return;
+
// Drop frame if previous decode is still running
if (this._decoding) {
this._droppedFrames++;
diff --git a/app/src/main/assets/web/js/main.js b/app/src/main/assets/web/js/main.js
index 8b088c2..f8a7b9a 100644
--- a/app/src/main/assets/web/js/main.js
+++ b/app/src/main/assets/web/js/main.js
@@ -1047,6 +1047,18 @@ document.addEventListener('DOMContentLoaded', async () => {
else resizeTimer = setTimeout(doSend, 500);
}
+ function waitForControlSocketOpen(timeoutMs) {
+ return new Promise((resolve) => {
+ const deadline = Date.now() + timeoutMs;
+ const check = () => {
+ if (controlSocket && controlSocket.readyState === WebSocket.OPEN) return resolve();
+ if (Date.now() >= deadline) return resolve(); // best-effort — fall through on timeout
+ setTimeout(check, 20);
+ };
+ check();
+ });
+ }
+
function connectControl() {
const wsUrl = `ws://${host}/ws/control`;
controlSocket = new WebSocket(wsUrl);
@@ -1218,26 +1230,64 @@ document.addEventListener('DOMContentLoaded', async () => {
if (!splitAppList) return;
splitAppList.innerHTML = '';
+ const grouped = {
+ 'NAVIGATION': { title: 'Navigation', color: '#4CAF50', items: [] },
+ 'VIDEO': { title: 'Video', color: '#FF5722', items: [] },
+ 'MUSIC': { title: 'Music', color: '#9C27B0', items: [] },
+ 'OTHER': { title: 'Apps', color: '#9E9E9E', items: [] }
+ };
+
apps.forEach(app => {
- const cell = document.createElement('div');
- cell.className = 'split-app-item';
+ if (grouped[app.category]) grouped[app.category].items.push(app);
+ else grouped['OTHER'].items.push(app);
+ });
- const icon = document.createElement('img');
- icon.className = 'split-app-icon';
- icon.src = `/api/icon?pkg=${app.packageName}`;
- cell.appendChild(icon);
+ Object.keys(grouped).forEach(key => {
+ const group = grouped[key];
+ if (group.items.length === 0) return;
- const label = document.createElement('div');
- label.textContent = SPLIT_STRATEGY === 'freeform' ? `${app.label} (Split)` : `${app.label} (Dual Stream)`;
- label.style.color = '#FFD700';
- cell.appendChild(label);
+ const section = document.createElement('div');
+ section.className = 'split-category-section';
- cell.addEventListener('click', () => {
- launchApp(app, true);
- splitDrawer.classList.remove('open');
+ const header = document.createElement('div');
+ header.className = 'split-category-header';
+ const bar = document.createElement('div');
+ bar.className = 'split-category-bar';
+ bar.style.backgroundColor = group.color;
+ const title = document.createElement('div');
+ title.className = 'split-category-title';
+ title.textContent = group.title;
+ header.appendChild(bar);
+ header.appendChild(title);
+ section.appendChild(header);
+
+ const items = document.createElement('div');
+ items.className = 'split-category-items';
+
+ group.items.forEach(app => {
+ const cell = document.createElement('div');
+ cell.className = 'split-app-item';
+
+ const icon = document.createElement('img');
+ icon.className = 'split-app-icon';
+ icon.src = `/api/icon?pkg=${app.packageName}`;
+ cell.appendChild(icon);
+
+ const label = document.createElement('div');
+ label.textContent = SPLIT_STRATEGY === 'freeform' ? `${app.label} (Split)` : `${app.label} (Dual Stream)`;
+ label.style.color = '#FFD700';
+ cell.appendChild(label);
+
+ cell.addEventListener('click', () => {
+ launchApp(app, true);
+ splitDrawer.classList.remove('open');
+ });
+
+ items.appendChild(cell);
});
- splitAppList.appendChild(cell);
+ section.appendChild(items);
+ splitAppList.appendChild(section);
});
}
@@ -1557,8 +1607,18 @@ document.addEventListener('DOMContentLoaded', async () => {
try {
await initDecoder();
- connectVideo();
- connectControl();
+ if (codecMode === 'mjpeg') {
+ // Open the control socket first so the `codec: mjpeg` preference
+ // reaches the server before the video socket starts streaming.
+ // Otherwise the server ships H.264 until it processes the switch,
+ // which an MJPEG decoder can't render.
+ connectControl();
+ await waitForControlSocketOpen(2000);
+ connectVideo();
+ } else {
+ connectVideo();
+ connectControl();
+ }
} catch (e) {
setStatus(e.message, 'error');
showOverlay();
diff --git a/app/src/main/java/com/castla/mirror/policy/CodecModeTransition.kt b/app/src/main/java/com/castla/mirror/policy/CodecModeTransition.kt
new file mode 100644
index 0000000..5fb50da
--- /dev/null
+++ b/app/src/main/java/com/castla/mirror/policy/CodecModeTransition.kt
@@ -0,0 +1,32 @@
+package com.castla.mirror.policy
+
+/**
+ * Pure decision for whether a client codec-mode request should trigger a
+ * pipeline rebuild.
+ *
+ * Encapsulates the guard used by `MirrorForegroundService.onCodecModeRequest`
+ * so it can be unit-tested without spinning up an Android Service. Keeps the
+ * orchestration (mutex, encoder tear-down, VD swap) in the service while the
+ * branching logic lives here.
+ */
+object CodecModeTransition {
+
+ const val MODE_H264 = "h264"
+ const val MODE_MJPEG = "mjpeg"
+
+ /**
+ * @param requestedMode mode string carried by the client control message
+ * @param currentMode the service's currently active codec mode
+ * @param jpegEncoderActive whether a JpegEncoder is already live
+ * @return true if the service should apply the switch (set mode + rebuild)
+ */
+ fun shouldApply(
+ requestedMode: String,
+ currentMode: String,
+ jpegEncoderActive: Boolean
+ ): Boolean {
+ if (requestedMode != MODE_MJPEG) return false
+ if (currentMode == MODE_MJPEG && jpegEncoderActive) return false
+ return true
+ }
+}
diff --git a/app/src/main/java/com/castla/mirror/service/MirrorForegroundService.kt b/app/src/main/java/com/castla/mirror/service/MirrorForegroundService.kt
index 22c01fd..2b6e5a4 100644
--- a/app/src/main/java/com/castla/mirror/service/MirrorForegroundService.kt
+++ b/app/src/main/java/com/castla/mirror/service/MirrorForegroundService.kt
@@ -44,6 +44,7 @@ import com.castla.mirror.utils.LaunchMode
import com.castla.mirror.policy.AutoScaleDecision
import com.castla.mirror.policy.AutoScaleInput
import com.castla.mirror.policy.AutoScalePolicy
+import com.castla.mirror.policy.CodecModeTransition
import com.castla.mirror.policy.DisconnectPolicy
import com.castla.mirror.policy.ScreenOffAction
import com.castla.mirror.policy.ScreenOffPolicy
@@ -61,6 +62,8 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import org.json.JSONObject
class MirrorForegroundService : Service() {
@@ -167,7 +170,8 @@ class MirrorForegroundService : Service() {
private var secondaryHeight: Int = 0
private var secondaryRequestedWidth: Int = 0
private var secondaryRequestedHeight: Int = 0
- private var currentCodecMode: String = "h264"
+ @Volatile private var currentCodecMode: String = "h264"
+ private val pipelineMutex = Mutex()
private var savedMediaVolume: Int = -1
private val mainHandler = Handler(Looper.getMainLooper())
private var splitPresentation: SplitWebPresentation? = null
@@ -2871,7 +2875,7 @@ class MirrorForegroundService : Service() {
return if (thermalCap != null) minOf(baseMax, thermalCap) else baseMax
}
- private suspend fun rebuildPipeline(newWidth: Int, newHeight: Int, force: Boolean = false) {
+ private suspend fun rebuildPipeline(newWidth: Int, newHeight: Int, force: Boolean = false) = pipelineMutex.withLock {
val effectiveMaxHeight = effectiveMaxHeightForRequest(newHeight)
var cappedWidth = newWidth
var cappedHeight = newHeight
@@ -2887,12 +2891,12 @@ class MirrorForegroundService : Service() {
if (!force && alignedWidth == currentWidth && alignedHeight == currentHeight) {
Log.d(TAG, "rebuildPipeline skipped: dimensions unchanged ${alignedWidth}x${alignedHeight}")
- return
+ return@withLock
}
if (alignedWidth < 320 || alignedWidth > 3840 || alignedHeight < 320 || alignedHeight > 3840) {
Log.w(TAG, "rebuildPipeline skipped: dimensions out of range ${alignedWidth}x${alignedHeight}")
- return
+ return@withLock
}
val width = alignedWidth
@@ -3023,63 +3027,19 @@ class MirrorForegroundService : Service() {
}
private fun onCodecModeRequest(mode: String) {
- if (mode != "mjpeg" || jpegEncoder != null) return
- currentCodecMode = "mjpeg"
-
- try {
- val jpeg = JpegEncoder(currentWidth, currentHeight, fps = 15, quality = 65)
- val surface = jpeg.createInputSurface()
- currentEncoderSurface = surface
-
- videoEncoder?.release()
- videoEncoder = null
-
- jpeg.start { frameData, isKeyFrame -> mirrorServer?.broadcastFrame(frameData, isKeyFrame) }
- jpegEncoder = jpeg
-
- if (virtualDisplayManager?.hasVirtualDisplay() == true) {
- dismissSplitPresentation(clearState = false)
- virtualDisplayManager?.releaseVirtualDisplay()
- virtualDisplayManager?.createVirtualDisplay(currentWidth, currentHeight, 160, surface)
- if (virtualDisplayManager?.hasVirtualDisplay() == true) {
- touchInjector?.setVirtualDisplayInjector { action, x, y, pointerId ->
- virtualDisplayManager?.injectInput(action, x, y, pointerId)
- }
- restoreCurrentVdContent()
- } else {
- // Do NOT fall back to MediaProjection here — it would mirror the
- // raw phone screen (showing the Castla UI) instead of virtual
- // display content. Retry VD creation once before giving up.
- Log.w(TAG, "MJPEG VD recreation failed — retrying once")
- virtualDisplayManager?.createVirtualDisplay(currentWidth, currentHeight, 160, surface)
- if (virtualDisplayManager?.hasVirtualDisplay() == true) {
- touchInjector?.setVirtualDisplayInjector { action, x, y, pointerId ->
- virtualDisplayManager?.injectInput(action, x, y, pointerId)
- }
- restoreCurrentVdContent()
- } else {
- Log.e(TAG, "MJPEG VD recreation failed after retry — NOT falling back to MediaProjection")
- }
- }
- } else if (virtualDisplayManager?.isBound() == true) {
- // Shizuku is bound but no VD yet — create one instead of falling back
- virtualDisplayManager?.createVirtualDisplay(currentWidth, currentHeight, 160, surface)
- if (virtualDisplayManager?.hasVirtualDisplay() == true) {
- touchInjector?.setVirtualDisplayInjector { action, x, y, pointerId ->
- virtualDisplayManager?.injectInput(action, x, y, pointerId)
- }
- restoreCurrentVdContent()
- } else {
- Log.e(TAG, "MJPEG VD creation failed — NOT falling back to MediaProjection")
+ if (!CodecModeTransition.shouldApply(mode, currentCodecMode, jpegEncoder != null)) return
+ currentCodecMode = CodecModeTransition.MODE_MJPEG
+ Log.i(TAG, "Codec mode request: mjpeg — delegating to rebuildPipeline")
+ serviceScope.launch {
+ try {
+ rebuildPipeline(currentWidth, currentHeight, force = true)
+ if (!singleVdSplit && secondaryWidth > 0 && secondaryHeight > 0) {
+ rebuildSecondaryPipeline(secondaryWidth, secondaryHeight)
}
- } else {
- // Shizuku not available at all — MediaProjection is the only option
- screenCapture?.reconfigure(surface, currentWidth, currentHeight)
- }
- if (!singleVdSplit && secondaryWidth > 0 && secondaryHeight > 0) {
- rebuildSecondaryPipeline(secondaryWidth, secondaryHeight)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to switch codec to mjpeg", e)
}
- } catch (e: Exception) {}
+ }
}
override fun onDestroy() {
diff --git a/app/src/test/java/com/castla/mirror/policy/CodecModeTransitionTest.kt b/app/src/test/java/com/castla/mirror/policy/CodecModeTransitionTest.kt
new file mode 100644
index 0000000..4ccd469
--- /dev/null
+++ b/app/src/test/java/com/castla/mirror/policy/CodecModeTransitionTest.kt
@@ -0,0 +1,34 @@
+package com.castla.mirror.policy
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class CodecModeTransitionTest {
+
+ @Test
+ fun `non-mjpeg request is rejected regardless of state`() {
+ assertFalse(CodecModeTransition.shouldApply("h264", "h264", jpegEncoderActive = false))
+ assertFalse(CodecModeTransition.shouldApply("h264", "mjpeg", jpegEncoderActive = true))
+ assertFalse(CodecModeTransition.shouldApply("", "h264", jpegEncoderActive = false))
+ assertFalse(CodecModeTransition.shouldApply("av1", "h264", jpegEncoderActive = false))
+ }
+
+ @Test
+ fun `mjpeg request from h264 applies`() {
+ assertTrue(CodecModeTransition.shouldApply("mjpeg", "h264", jpegEncoderActive = false))
+ }
+
+ @Test
+ fun `mjpeg request when already mjpeg with active encoder is a no-op`() {
+ assertFalse(CodecModeTransition.shouldApply("mjpeg", "mjpeg", jpegEncoderActive = true))
+ }
+
+ @Test
+ fun `mjpeg request when mode is mjpeg but encoder is missing still applies`() {
+ // Covers the case where the mode flag was set but the previous rebuild
+ // failed to finish creating the JpegEncoder — the next request must
+ // still trigger a rebuild instead of silently skipping.
+ assertTrue(CodecModeTransition.shouldApply("mjpeg", "mjpeg", jpegEncoderActive = false))
+ }
+}