diff --git a/app/src/main/java/com/foxx/androidcast/CastConfig.java b/app/src/main/java/com/foxx/androidcast/CastConfig.java
index 9ce00a8..3e41ef1 100644
--- a/app/src/main/java/com/foxx/androidcast/CastConfig.java
+++ b/app/src/main/java/com/foxx/androidcast/CastConfig.java
@@ -24,7 +24,8 @@ public final class CastConfig {
public static final boolean SHOW_CAST_DIAGNOSTICS = true;
/** No video packets for this long → treat stream as stopped (robot overlay). */
- public static final long STREAM_IDLE_TIMEOUT_MS = 4_000;
+ /** No inbound video this long → robot overlay (keep above ~2× slowest expected frame interval). */
+ public static final long STREAM_IDLE_TIMEOUT_MS = 8_000;
private CastConfig() {}
}
diff --git a/app/src/main/java/com/foxx/androidcast/CastResolution.java b/app/src/main/java/com/foxx/androidcast/CastResolution.java
index cbfebff..13ef2a8 100644
--- a/app/src/main/java/com/foxx/androidcast/CastResolution.java
+++ b/app/src/main/java/com/foxx/androidcast/CastResolution.java
@@ -31,6 +31,12 @@ public final class CastResolution {
encH = maxH & ~1;
encW = Math.max(2, ((int) (encW * ratio)) & ~1);
}
+ int maxW = settings.getMaxWidth();
+ if (maxW > 0 && encW > maxW) {
+ float ratio = (float) maxW / encW;
+ encW = maxW & ~1;
+ encH = Math.max(2, ((int) (encH * ratio)) & ~1);
+ }
int density = Math.max(120, (int) (display.densityDpi * scale));
return new Size(encW, encH, density);
diff --git a/app/src/main/java/com/foxx/androidcast/CastSettings.java b/app/src/main/java/com/foxx/androidcast/CastSettings.java
index 49924a7..c8a3538 100644
--- a/app/src/main/java/com/foxx/androidcast/CastSettings.java
+++ b/app/src/main/java/com/foxx/androidcast/CastSettings.java
@@ -22,17 +22,19 @@ public class CastSettings implements Serializable {
/** Capture scale relative to physical display (1.0 = native). */
public enum Resolution {
- FULL(1.0f, 0),
- THREE_QUARTERS(0.75f, 0),
- HALF(0.5f, 0),
- HD_720P(1.0f, 720);
+ FULL(1.0f, 0, 0),
+ THREE_QUARTERS(0.75f, 0, 0),
+ HALF(0.5f, 0, 0),
+ HD_720P(1.0f, 720, 1280);
public final float scale;
public final int maxHeight;
+ public final int maxWidth;
- Resolution(float scale, int maxHeight) {
+ Resolution(float scale, int maxHeight, int maxWidth) {
this.scale = scale;
this.maxHeight = maxHeight;
+ this.maxWidth = maxWidth;
}
}
@@ -97,4 +99,8 @@ public class CastSettings implements Serializable {
public int getMaxHeight() {
return resolution.maxHeight;
}
+
+ public int getMaxWidth() {
+ return resolution.maxWidth;
+ }
}
diff --git a/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java b/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java
index dab55a2..87d0c7c 100644
--- a/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java
+++ b/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java
@@ -15,6 +15,8 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
private final NetworkControlAdvice outbound = NetworkControlAdvice.none();
private long statsWindowBytes;
private long statsWindowStartMs;
+ private long sendWindowBytes;
+ private long sendWindowStartMs;
@Override
public NetworkControlAdvice tick(long nowMs) {
@@ -41,7 +43,7 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
}
}
- /** Receiver calls this when a video/audio packet arrives (for local bitrate estimate). */
+ /** Record inbound (receiver) or outbound (sender) payload bytes for bitrate stats. */
public void recordBytes(int bytes) {
long now = System.currentTimeMillis();
if (statsWindowStartMs == 0) {
@@ -50,6 +52,14 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
statsWindowBytes += bytes;
}
+ public void recordSendBytes(int bytes) {
+ long now = System.currentTimeMillis();
+ if (sendWindowStartMs == 0) {
+ sendWindowStartMs = now;
+ }
+ sendWindowBytes += bytes;
+ }
+
@Override
public NetworkStatsSnapshot buildLocalStats(long nowMs) {
NetworkStatsSnapshot s = new NetworkStatsSnapshot();
@@ -57,13 +67,29 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
s.timestampMs = nowMs;
s.rttMs = lastPeer != null ? lastPeer.rttMs : 0;
s.lossRatio = 0f;
- s.sendBitrateKbps = 0;
+ s.sendBitrateKbps = estimateSendKbps(nowMs);
s.recvBitrateKbps = estimateRecvKbps(nowMs);
s.congestionLevel = 0;
s.jitterMs = 0;
return s;
}
+ private int estimateSendKbps(long nowMs) {
+ if (sendWindowStartMs == 0 || sendWindowBytes == 0) {
+ return 0;
+ }
+ long elapsed = nowMs - sendWindowStartMs;
+ if (elapsed < 200) {
+ return 0;
+ }
+ int kbps = (int) (sendWindowBytes * 8L / elapsed);
+ if (elapsed > 4000) {
+ sendWindowStartMs = nowMs;
+ sendWindowBytes = 0;
+ }
+ return kbps;
+ }
+
private int estimateRecvKbps(long nowMs) {
if (statsWindowStartMs == 0 || statsWindowBytes == 0) {
return 0;
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
index acc643a..2e94fcb 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
@@ -208,6 +208,7 @@ public class ReceiverCastService extends Service {
}
if (keyFrame) {
awaitingKeyframe = false;
+ reconfiguring = false;
}
final int frameBytes = data != null ? data.length : 0;
mainHandler.post(() -> {
@@ -392,10 +393,12 @@ public class ReceiverCastService extends Service {
Log.d(TAG, "Stream config unchanged " + size.width + "x" + size.height);
return;
}
+ boolean firstConfig = !hasPendingConfig;
phase = Phase.STREAMING;
streamIdle = false;
- streamMetrics.reset();
- lastVideoFrameMs = 0;
+ if (firstConfig) {
+ streamMetrics.reset();
+ }
reconfiguring = true;
awaitingKeyframe = true;
openPlaybackAwaiting(R.string.playback_awaiting_stream);
@@ -405,10 +408,8 @@ public class ReceiverCastService extends Service {
pendingCsd1 = csd1;
hasPendingConfig = true;
decoderActive = false;
- mainHandler.postDelayed(() -> awaitingKeyframe = false, 2000);
notifyPlaybackDimensions(pendingWidth, pendingHeight);
scheduleDecoderConfigureRetries();
- reconfiguring = false;
Log.i(TAG, "Stream config " + pendingWidth + "x" + pendingHeight);
}
@@ -514,6 +515,7 @@ public class ReceiverCastService extends Service {
}
});
decoderActive = true;
+ reconfiguring = false;
decoderWidth = pendingWidth;
decoderHeight = pendingHeight;
updateStatus(getString(R.string.playing_resolution, pendingWidth, pendingHeight));
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/StreamMetrics.java b/app/src/main/java/com/foxx/androidcast/receiver/StreamMetrics.java
index 1abec58..0545239 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/StreamMetrics.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/StreamMetrics.java
@@ -112,11 +112,22 @@ public final class StreamMetrics {
sb.append("BW in (est): ").append(localKbps > 0 ? localKbps + " kbps" : "n/a");
sb.append(" RTT: ").append(peerRttMs > 0 ? peerRttMs + " ms" : "n/a").append('\n');
if (peerRecvKbps > 0 || peerSendKbps > 0) {
- sb.append("Peer stats: rx ").append(peerRecvKbps).append(" / tx ")
- .append(peerSendKbps).append(" kbps\n");
+ sb.append("Sender (peer): tx ").append(peerSendKbps).append(" / rx ")
+ .append(peerRecvKbps).append(" kbps\n");
}
sb.append("Video pkts: ").append(videoPackets);
- sb.append(" (IDR ").append(keyFrames).append(" / P ").append(deltaFrames).append(")\n");
+ sb.append(" (IDR ").append(keyFrames).append(" / P ").append(deltaFrames).append(')');
+ if (videoPackets > 0) {
+ int avgKb = (int) (videoBytes / videoPackets / 1024);
+ sb.append(" ~").append(Math.max(1, avgKb)).append(" KB/frame");
+ }
+ sb.append('\n');
+ if (inFps > 0 && inFps < 10f) {
+ sb.append("Hint: low In FPS → sender encode or TCP backlog\n");
+ }
+ if (renderFps > 0 && inFps > renderFps + 3f) {
+ sb.append("Hint: decode slower than network\n");
+ }
sb.append("Audio pkts: ").append(audioPackets).append('\n');
sb.append("Video bytes: ").append(formatBytes(videoBytes));
sb.append(" Audio bytes: ").append(formatBytes(audioBytes)).append('\n');
diff --git a/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java b/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java
index f049140..10d763b 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java
@@ -4,6 +4,7 @@ import android.util.Log;
import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.CastSession;
+import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
import java.io.IOException;
import java.util.concurrent.LinkedBlockingQueue;
@@ -21,10 +22,25 @@ public final class CastSendPump {
private Thread thread;
private CastSession session;
private Runnable onSendFailed;
+ private PassThroughNetworkControlPlane statsPlane;
+ private volatile int droppedPFrames;
+
+ public int getDroppedPFrames() {
+ return droppedPFrames;
+ }
+
+ public int getQueueDepth() {
+ return queue.size();
+ }
public void start(CastSession session, Runnable onSendFailed) {
+ start(session, onSendFailed, null);
+ }
+
+ public void start(CastSession session, Runnable onSendFailed, PassThroughNetworkControlPlane statsPlane) {
this.session = session;
this.onSendFailed = onSendFailed;
+ this.statsPlane = statsPlane;
if (!running.compareAndSet(false, true)) {
return;
}
@@ -57,11 +73,14 @@ public final class CastSendPump {
return;
}
if (queue.size() >= MAX_P_FRAMES_BACKLOG) {
+ droppedPFrames++;
return;
}
if (!queue.offer(item)) {
queue.poll();
- queue.offer(item);
+ if (!queue.offer(item)) {
+ droppedPFrames++;
+ }
}
}
@@ -82,7 +101,11 @@ public final class CastSendPump {
if (item == null || session == null) {
continue;
}
- session.send(item.type, item.buildPayload());
+ byte[] payload = item.buildPayload();
+ session.send(item.type, payload);
+ if (statsPlane != null) {
+ statsPlane.recordSendBytes(payload.length + 5);
+ }
} catch (InterruptedException e) {
if (!running.get()) {
break;
diff --git a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
index d296754..b4ee14f 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
@@ -31,6 +31,7 @@ import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.CastSession;
import com.foxx.androidcast.network.CastTransportFactory;
import com.foxx.androidcast.network.control.NetworkFeedbackManager;
+import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
import java.io.IOException;
import java.util.concurrent.CountDownLatch;
@@ -159,9 +160,11 @@ public class ScreenCastService extends Service {
connectWithRetry(host, port, settings);
updateStatus(getString(R.string.authenticating));
session.clientHandshake(deviceName != null ? deviceName : "Sender", pin, settings);
- sendPump = new CastSendPump();
- sendPump.start(session, () -> stopping.set(true));
networkFeedback = new NetworkFeedbackManager(session, true);
+ PassThroughNetworkControlPlane plane =
+ (PassThroughNetworkControlPlane) networkFeedback.getPlane();
+ sendPump = new CastSendPump();
+ sendPump.start(session, () -> stopping.set(true), plane);
casting.set(true);
activeSettings = settings;
startCaptureOnMainThread(resultCode, new Intent(data), settings);
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 208f5e8..5ee8bca 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -28,7 +28,7 @@
Full display
75% scale
50% scale
- Cap height 720p
+ 720p (max 1280×720)
Transport must match the receiver (TCP/UDP)
Select a receiver first
Enter PIN