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