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
8 changes: 4 additions & 4 deletions app/src/main/assets/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -721,9 +721,9 @@
🏠
</button>

<div id="stream-resolution-control"
<div id="density-control"
style="position:fixed;bottom:12px;left:72px;z-index:201;display:none;">
<button id="stream-resolution-btn"
<button id="density-btn"
style="height:48px;padding:0 14px;border-radius:24px;border:none;
background:rgba(0,0,0,0.6);color:#fff;font-size:13px;
font-weight:500;cursor:pointer;backdrop-filter:blur(4px);
Expand All @@ -735,9 +735,9 @@
<line x1="8" y1="20" x2="16" y2="20"/>
<line x1="12" y1="18" x2="12" y2="20"/>
</svg>
<span id="stream-resolution-label">Auto</span>
<span id="density-label">Small</span>
</button>
<div id="stream-resolution-popup"
<div id="density-popup"
style="position:absolute;bottom:56px;left:0;
background:rgba(20,20,30,0.95);backdrop-filter:blur(10px);
border-radius:12px;padding:6px;display:none;
Expand Down
96 changes: 56 additions & 40 deletions app/src/main/assets/web/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,15 @@ let userPreferredProfile = (() => {
})();
let currentThermalLevel = 'none';
let playbackProfile = userPreferredProfile;
let streamResolutionMode = 'auto';
const DENSITY_STORAGE_KEY = 'castla_display_density';
let currentDensity = (() => {
try {
const saved = parseFloat(localStorage.getItem(DENSITY_STORAGE_KEY));
return Number.isFinite(saved) ? saved : 0.7;
} catch (_) {
return 0.7;
}
})();
let streamPolicy = {
fitMode: 'contain',
autoFit: false,
Expand All @@ -65,10 +73,10 @@ document.addEventListener('DOMContentLoaded', async () => {

const webLauncher = document.getElementById('web-launcher');
const homeBtn = document.getElementById('home-btn');
const resolutionControl = document.getElementById('stream-resolution-control');
const resolutionBtn = document.getElementById('stream-resolution-btn');
const resolutionLabel = document.getElementById('stream-resolution-label');
const resolutionPopup = document.getElementById('stream-resolution-popup');
const densityControl = document.getElementById('density-control');
const densityBtn = document.getElementById('density-btn');
const densityLabel = document.getElementById('density-label');
const densityPopup = document.getElementById('density-popup');
const overlay = document.getElementById('overlay');
const statusText = document.getElementById('status');
const launcherLoading = document.getElementById('launcher-loading');
Expand Down Expand Up @@ -1006,6 +1014,10 @@ document.addEventListener('DOMContentLoaded', async () => {
controlSocket.send(JSON.stringify({ type: 'codec', mode: 'mjpeg' }));
}

if (controlSocket.readyState === WebSocket.OPEN) {
controlSocket.send(JSON.stringify({ type: 'displayDensity', scale: currentDensity }));
}

if (isLauncherMode) {
loadLauncherApps();
}
Expand Down Expand Up @@ -1082,8 +1094,6 @@ document.addEventListener('DOMContentLoaded', async () => {
else blurKeyboardProxy();
} else if (msg.type === 'thermalStatus') {
handleThermalProfileSwitch(msg.level);
} else if (msg.type === 'streamSettings') {
applyResolutionMode(msg.resolutionMode || 'auto');
} else if (msg.type === 'autoTierChange') {
const tier = msg.tier;
const reason = msg.reason;
Expand Down Expand Up @@ -1543,26 +1553,39 @@ document.addEventListener('DOMContentLoaded', async () => {
if (canvas) canvas.style.pointerEvents = 'auto';
if (secondaryCanvas) secondaryCanvas.style.pointerEvents = 'auto';

// ── Stream Resolution UI ──
const RESOLUTION_OPTIONS = [
{ value: 'auto', label: 'Auto' },
{ value: '720p', label: '720p' },
{ value: '1080p', label: '1080p' }
// ── Display Density UI ──
const DENSITY_LEVELS = [
{ value: 1.0, label: 'Large' },
{ value: 0.85, label: 'Default' },
{ value: 0.7, label: 'Small' },
{ value: 0.55, label: 'Compact' }
];

function applyResolutionMode(mode) {
streamResolutionMode = RESOLUTION_OPTIONS.some(opt => opt.value === mode) ? mode : 'auto';
const opt = RESOLUTION_OPTIONS.find(p => p.value === streamResolutionMode) || RESOLUTION_OPTIONS[0];
if (resolutionLabel) resolutionLabel.textContent = opt.label;
if (resolutionPopup) buildResolutionPopup();
function normalizeDensity(scale) {
return DENSITY_LEVELS.some(level => level.value === scale) ? scale : 0.7;
}

function applyDensity(scale) {
currentDensity = normalizeDensity(scale);
try { localStorage.setItem(DENSITY_STORAGE_KEY, String(currentDensity)); } catch (_) {}
const level = DENSITY_LEVELS.find(item => item.value === currentDensity) || DENSITY_LEVELS[2];
if (densityLabel) densityLabel.textContent = level.label;
if (densityPopup) buildDensityPopup();
}

function buildResolutionPopup() {
if (!resolutionPopup) return;
resolutionPopup.innerHTML = '';
RESOLUTION_OPTIONS.forEach(option => {
function sendDensity(scale) {
applyDensity(scale);
if (controlSocket && controlSocket.readyState === WebSocket.OPEN) {
controlSocket.send(JSON.stringify({ type: 'displayDensity', scale: currentDensity }));
}
}

function buildDensityPopup() {
if (!densityPopup) return;
densityPopup.innerHTML = '';
DENSITY_LEVELS.forEach(option => {
const btn = document.createElement('button');
const isActive = option.value === streamResolutionMode;
const isActive = option.value === currentDensity;
btn.textContent = option.label;
btn.style.cssText = `
display:block;width:100%;padding:10px 16px;border:none;
Expand All @@ -1574,24 +1597,17 @@ document.addEventListener('DOMContentLoaded', async () => {
`;
btn.addEventListener('click', (e) => {
e.stopPropagation();
applyResolutionMode(option.value);
resolutionPopup.style.display = 'none';
buildResolutionPopup();
if (controlSocket && controlSocket.readyState === WebSocket.OPEN) {
controlSocket.send(JSON.stringify({
type: 'streamSettings',
resolutionMode: option.value
}));
}
sendDensity(option.value);
densityPopup.style.display = 'none';
});
resolutionPopup.appendChild(btn);
densityPopup.appendChild(btn);
});
}

function updateOverlayControlsVisibility() {
const isVisible = homeBtn && homeBtn.style.display !== 'none';
if (profileControl) profileControl.style.display = isVisible ? 'block' : 'none';
if (resolutionControl) resolutionControl.style.display = isVisible ? 'block' : 'none';
if (densityControl) densityControl.style.display = isVisible ? 'block' : 'none';
}

// ── Playback Profile UI ──
Expand Down Expand Up @@ -1722,17 +1738,17 @@ document.addEventListener('DOMContentLoaded', async () => {
});
}

if (resolutionBtn && resolutionPopup) {
applyResolutionMode(streamResolutionMode);
buildResolutionPopup();
if (densityBtn && densityPopup) {
applyDensity(currentDensity);
buildDensityPopup();

resolutionBtn.addEventListener('click', (e) => {
densityBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isVisible = resolutionPopup.style.display === 'block';
resolutionPopup.style.display = isVisible ? 'none' : 'block';
const isVisible = densityPopup.style.display === 'block';
densityPopup.style.display = isVisible ? 'none' : 'block';
});
document.addEventListener('click', () => {
if (resolutionPopup) resolutionPopup.style.display = 'none';
if (densityPopup) densityPopup.style.display = 'none';
});
}

Expand Down
6 changes: 0 additions & 6 deletions app/src/main/java/com/castla/mirror/server/ControlSocket.kt
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,6 @@ class ControlSocket(
backlogDrops = json.optInt("backlogDrops", 0)
)
}
"streamSettings" -> {
val resolutionMode = json.optString("resolutionMode", "")
if (resolutionMode == "auto" || resolutionMode == "720p" || resolutionMode == "1080p") {
server.onStreamResolutionChange(resolutionMode)
}
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to parse control message", e)
Expand Down
14 changes: 0 additions & 14 deletions app/src/main/java/com/castla/mirror/server/MirrorServer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ class MirrorServer(private val context: Context) : NanoWSD(DEFAULT_PORT) {
private var onCloseSplitListener: (() -> Unit)? = null
private var onDisplayDensityListener: ((Float) -> Unit)? = null
private var onQualityReportListener: ((Int, Double, Int) -> Unit)? = null
private var onStreamResolutionListener: ((String) -> Unit)? = null

// Track active connection status
private var isBrowserConnected = false
Expand All @@ -49,7 +48,6 @@ class MirrorServer(private val context: Context) : NanoWSD(DEFAULT_PORT) {
// Cached thermal status JSON — sent immediately to new control sockets
// to prevent race where browser connects before thermal broadcast arrives.
@Volatile private var cachedThermalJson: String? = null
@Volatile private var cachedStreamSettingsJson: String? = null

private var cachedSpsPps: ByteArray? = null

Expand Down Expand Up @@ -115,9 +113,6 @@ class MirrorServer(private val context: Context) : NanoWSD(DEFAULT_PORT) {
onQualityReportListener = listener
}

fun setStreamResolutionListener(listener: (String) -> Unit) {
onStreamResolutionListener = listener
}

fun onCloseSplitRequest() {
onCloseSplitListener?.invoke()
Expand Down Expand Up @@ -166,10 +161,6 @@ class MirrorServer(private val context: Context) : NanoWSD(DEFAULT_PORT) {
try { socket.send(json) }
catch (e: Exception) { Log.w(TAG, "Failed to send cached thermal status", e) }
}
cachedStreamSettingsJson?.let { json ->
try { socket.send(json) }
catch (e: Exception) { Log.w(TAG, "Failed to send cached stream settings", e) }
}

updateConnectionState()
}
Expand Down Expand Up @@ -293,8 +284,6 @@ class MirrorServer(private val context: Context) : NanoWSD(DEFAULT_PORT) {
// Cache thermal status so new control sockets receive it immediately
if (json.contains("\"thermalStatus\"")) {
cachedThermalJson = json
} else if (json.contains("\"streamSettings\"")) {
cachedStreamSettingsJson = json
}
val deadSockets = mutableListOf<ControlSocket>()
for (socket in controlSockets) {
Expand Down Expand Up @@ -356,9 +345,6 @@ class MirrorServer(private val context: Context) : NanoWSD(DEFAULT_PORT) {
onQualityReportListener?.invoke(droppedFrames, avgDelayMs, backlogDrops)
}

fun onStreamResolutionChange(mode: String) {
onStreamResolutionListener?.invoke(mode)
}

override fun openWebSocket(handshake: IHTTPSession): WebSocket {
val uri = handshake.uri
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@ class MirrorForegroundService : Service() {
// Thermal fps/resolution overrides — applied by rebuildPipeline when non-null
private var thermalFpsOverride: Int? = null
private var thermalMaxHeight: Int? = null
// Display density scale (0.85 = default, lower = smaller UI / more content)
private var dpiScale: Float = 0.85f
// Display density scale (0.7 = default small, lower = more compact UI / more content)
private var dpiScale: Float = 0.7f

// Auto mode: dynamically adjusts resolution/fps based on conditions.
// When true, the service starts conservatively (720p/30fps) and steps up
Expand Down Expand Up @@ -713,73 +713,11 @@ class MirrorForegroundService : Service() {
mirrorServer?.broadcastControlMessage(json)
}

private fun currentResolutionMode(): String = when {
autoResolution -> "auto"
currentMaxHeight <= 720 -> "720p"
else -> "1080p"
}

private fun broadcastStreamSettingsState() {
val json = JSONObject().apply {
put("type", "streamSettings")
put("resolutionMode", currentResolutionMode())
put("autoResolution", autoResolution)
put("currentMaxHeight", currentMaxHeight)
put("currentFps", currentFps)
}.toString()
mirrorServer?.broadcastControlMessage(json)
}

private fun refreshAutoScaleLoop(resetTier: Boolean = false) {
if (autoResolution || autoFps) {
if (resetTier || autoScaleJob?.isActive != true) {
startAutoScaleLoop()
}
} else {
autoScaleJob?.cancel()
}
}

private fun onStreamResolutionChange(mode: String) {
val previousAuto = autoResolution
val previousHeight = currentMaxHeight

when (mode) {
"auto" -> {
autoResolution = true
autoTierIndex = 0
autoStableCount = 0
currentMaxHeight = AUTO_TIERS.first().maxHeight
}
"720p" -> {
autoResolution = false
currentMaxHeight = 720
}
"1080p" -> {
autoResolution = false
currentMaxHeight = 1080
}
else -> return
}

refreshAutoScaleLoop(resetTier = mode == "auto")
broadcastStreamSettingsState()

if ((previousAuto != autoResolution || previousHeight != currentMaxHeight) &&
browserConnected && currentWidth > 0 && currentHeight > 0
) {
serviceScope.launch {
rebuildPipeline(currentWidth, currentHeight, force = true)
}
}
}

private fun applyAutoTier() {
val tier = AUTO_TIERS[autoTierIndex]
// Only apply auto values for settings that are in auto mode
if (autoResolution) currentMaxHeight = tier.maxHeight
if (autoFps) currentFps = tier.fps
broadcastStreamSettingsState()
// Trigger pipeline rebuild with new settings
if (browserConnected && currentWidth > 0 && currentHeight > 0) {
serviceScope.launch {
Expand Down Expand Up @@ -893,10 +831,6 @@ class MirrorForegroundService : Service() {
lastQualityBacklogDrops = backlogDrops
}

server.setStreamResolutionListener { mode ->
Log.i(TAG, "Stream resolution mode changed to $mode")
onStreamResolutionChange(mode)
}

server.setBrowserConnectionListener { connected ->
if (connected && !browserConnected) {
Expand All @@ -910,7 +844,6 @@ class MirrorForegroundService : Service() {
}

server.start(0)
broadcastStreamSettingsState()
Log.i(TAG, "Server started on port ${MirrorServer.DEFAULT_PORT} — waiting for browser")
}

Expand Down
4 changes: 2 additions & 2 deletions app/src/main/java/com/castla/mirror/utils/StreamMath.kt
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ object StreamMath {
return (height * 240 / 720).coerceIn(120, 320)
}

/** Default display density scale. */
const val DENSITY_SCALE_DEFAULT = 0.85f
/** Default display density scale (Small). */
const val DENSITY_SCALE_DEFAULT = 0.7f

/** All supported density scale values, from largest (original) to most compact. */
val DENSITY_SCALE_OPTIONS = listOf(1.0f, 0.85f, 0.7f, 0.55f)
Expand Down
10 changes: 5 additions & 5 deletions app/src/test/java/com/castla/mirror/utils/StreamMathTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,8 @@ class StreamMathTest {
// ── Display density scale tests ──

@Test
fun `test density scale default is 0_85`() {
assertEquals(0.85f, StreamMath.DENSITY_SCALE_DEFAULT)
fun `test density scale default is 0_7`() {
assertEquals(0.7f, StreamMath.DENSITY_SCALE_DEFAULT)
}

@Test
Expand All @@ -139,9 +139,9 @@ class StreamMathTest {

@Test
fun `test applyDensityScale at default`() {
// 720p base DPI = 240, default scale = 0.85
// 240 * 0.85 = 204
assertEquals(204, StreamMath.applyDensityScale(240, 0.85f))
// 720p base DPI = 240, default scale = 0.7
// 240 * 0.7 = 168
assertEquals(168, StreamMath.applyDensityScale(240, StreamMath.DENSITY_SCALE_DEFAULT))
}

@Test
Expand Down
Loading