Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions app/src/main/assets/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/assets/web/js/fallback.js
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
Expand Down
92 changes: 76 additions & 16 deletions app/src/main/assets/web/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
});
}

Expand Down Expand Up @@ -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();
Expand Down
32 changes: 32 additions & 0 deletions app/src/main/java/com/castla/mirror/policy/CodecModeTransition.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}