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)) + } +}