1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 04:38:53 +03:00
This commit is contained in:
Anton Afanasyeu
2026-05-15 18:17:39 +02:00
parent 4de71d5400
commit 17fddbb905
19 changed files with 562 additions and 40 deletions

View File

@@ -10,4 +10,5 @@ interface ICastReceiverService {
void attachVideoSurface(in Surface surface);
void detachVideoSurface();
void stopReceiving();
String getDiagnosticsText();
}

View File

@@ -5,5 +5,6 @@ interface ICastStatusCallback {
void onAuthenticated(String senderName);
void onStreamStarted(int width, int height);
void onVideoRendering();
void onStreamIdle();
void onDisconnected();
}

View File

@@ -19,5 +19,11 @@ public final class CastConfig {
/** Max UDP payload chunk (fits typical WiFi MTU with header). */
public static final int UDP_CHUNK_SIZE = 1200;
/** Show diagnostics overlay on receiver playback (debug). */
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;
private CastConfig() {}
}

View File

@@ -80,6 +80,12 @@ public class DiscoveryManager {
devices.clear();
}
/** Clears cached devices and notifies listener with an empty list. */
public void clearDevices() {
devices.clear();
notifyListener();
}
public void startAnnouncing(String deviceName, int port, String transport) {
if (!running.compareAndSet(false, true)) {
return;

View File

@@ -244,8 +244,8 @@ public final class CastProtocol {
if (len == 0) {
return new byte[0];
}
if (len < 0 || len > 512 * 1024) {
throw new IOException("Invalid blob length");
if (len < 0 || len > 2 * 1024 * 1024) {
throw new IOException("Invalid blob length: " + len);
}
byte[] buf = new byte[len];
dis.readFully(buf);

View File

@@ -1,5 +1,7 @@
package com.foxx.androidcast.network;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
@@ -12,12 +14,14 @@ import java.net.SocketTimeoutException;
/** Reliable message transport over TCP (serialized send/receive). */
public class TcpCastTransport implements CastTransport {
private static final int CONNECT_TIMEOUT_MS = 10_000;
private static final int HEADER_BYTES = 5; // type + int length
private final Object sendLock = new Object();
private final Object receiveLock = new Object();
private ServerSocket serverSocket;
private Socket socket;
private BufferedInputStream bufferedIn;
private DataInputStream in;
private DataOutputStream out;
@@ -46,8 +50,9 @@ public class TcpCastTransport implements CastTransport {
}
private void openStreams() throws IOException {
in = new DataInputStream(socket.getInputStream());
out = new DataOutputStream(socket.getOutputStream());
bufferedIn = new BufferedInputStream(socket.getInputStream(), 256 * 1024);
in = new DataInputStream(bufferedIn);
out = new DataOutputStream(new BufferedOutputStream(socket.getOutputStream()));
}
@Override
@@ -60,16 +65,21 @@ public class TcpCastTransport implements CastTransport {
}
}
/**
* Waits up to {@code timeoutMs} for data, then reads one full message without a socket read timeout.
* Avoids partial reads that desync the stream (root cause of "Invalid payload length" garbage).
*/
@Override
public CastProtocol.Message receive(int timeoutMs) throws IOException {
synchronized (receiveLock) {
if (socket == null || socket.isClosed() || in == null) {
return null;
}
if (timeoutMs > 0) {
socket.setSoTimeout(timeoutMs);
if (timeoutMs > 0 && !waitForHeader(timeoutMs)) {
return null;
}
try {
socket.setSoTimeout(0);
return CastProtocol.readMessage(in);
} catch (SocketTimeoutException e) {
return null;
@@ -84,6 +94,22 @@ public class TcpCastTransport implements CastTransport {
}
}
private boolean waitForHeader(int timeoutMs) throws IOException {
long deadline = System.currentTimeMillis() + timeoutMs;
while (System.currentTimeMillis() < deadline) {
if (bufferedIn.available() >= HEADER_BYTES) {
return true;
}
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
return bufferedIn.available() >= HEADER_BYTES;
}
@Override
public String getModeLabel() {
return "TCP";
@@ -113,6 +139,7 @@ public class TcpCastTransport implements CastTransport {
}
socket = null;
serverSocket = null;
bufferedIn = null;
in = null;
out = null;
}

View File

@@ -13,6 +13,8 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
private long lastStatsMs;
private NetworkStatsSnapshot lastPeer;
private final NetworkControlAdvice outbound = NetworkControlAdvice.none();
private long statsWindowBytes;
private long statsWindowStartMs;
@Override
public NetworkControlAdvice tick(long nowMs) {
@@ -30,7 +32,22 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
@Override
public void onPeerStats(NetworkStatsSnapshot stats) {
lastPeer = stats;
lastPeer = stats != null ? stats.copy() : null;
if (lastPeer != null && lastPeer.timestampMs > 0) {
long rtt = System.currentTimeMillis() - lastPeer.timestampMs;
if (rtt > 0 && rtt < 10_000) {
lastPeer.rttMs = (int) rtt;
}
}
}
/** Receiver calls this when a video/audio packet arrives (for local bitrate estimate). */
public void recordBytes(int bytes) {
long now = System.currentTimeMillis();
if (statsWindowStartMs == 0) {
statsWindowStartMs = now;
}
statsWindowBytes += bytes;
}
@Override
@@ -41,12 +58,28 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
s.rttMs = lastPeer != null ? lastPeer.rttMs : 0;
s.lossRatio = 0f;
s.sendBitrateKbps = 0;
s.recvBitrateKbps = 0;
s.recvBitrateKbps = estimateRecvKbps(nowMs);
s.congestionLevel = 0;
s.jitterMs = 0;
return s;
}
private int estimateRecvKbps(long nowMs) {
if (statsWindowStartMs == 0 || statsWindowBytes == 0) {
return 0;
}
long elapsed = nowMs - statsWindowStartMs;
if (elapsed < 200) {
return 0;
}
int kbps = (int) (statsWindowBytes * 8L / elapsed);
if (elapsed > 4000) {
statsWindowStartMs = nowMs;
statsWindowBytes = 0;
}
return kbps;
}
@Override
public void onPeerControl(int suggestedBitrateKbps, String suggestedTransport,
boolean switchTransport, int congestionLevel) {

View File

@@ -57,6 +57,11 @@ public class ReceiverActivity extends AppCompatActivity {
runOnUiThread(() -> statusText.setText(R.string.playback_rendering));
}
@Override
public void onStreamIdle() {
runOnUiThread(() -> statusText.setText(R.string.playback_awaiting_stream));
}
@Override
public void onDisconnected() {
runOnUiThread(() -> statusText.setText(R.string.waiting_sender));

View File

@@ -19,7 +19,9 @@ import com.foxx.androidcast.ICastReceiverService;
import com.foxx.androidcast.ICastStatusCallback;
import com.foxx.androidcast.IntentExtras;
import com.foxx.androidcast.R;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.discovery.DiscoveryManager;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import java.util.Arrays;
@@ -71,6 +73,17 @@ public class ReceiverCastService extends Service {
private byte[] pendingAudioCsd;
private PowerManager.WakeLock wakeLock;
private String activePin = com.foxx.androidcast.CastConfig.DEFAULT_PIN;
private final StreamMetrics streamMetrics = new StreamMetrics();
private boolean streamIdle;
private long lastVideoFrameMs;
private final Runnable idleWatchdog = new Runnable() {
@Override
public void run() {
checkStreamIdle();
mainHandler.postDelayed(this, 500);
}
};
private final ICastReceiverService.Stub binder = new ICastReceiverService.Stub() {
@Override
@@ -107,12 +120,18 @@ public class ReceiverCastService extends Service {
public void stopReceiving() {
stopSelfSafely();
}
@Override
public String getDiagnosticsText() {
return buildDiagnosticsText();
}
};
@Override
public void onCreate() {
super.onCreate();
CastNotifications.createChannels(this);
mainHandler.post(idleWatchdog);
}
@Override
@@ -188,7 +207,13 @@ public class ReceiverCastService extends Service {
if (keyFrame) {
awaitingKeyframe = false;
}
final int frameBytes = data != null ? data.length : 0;
mainHandler.post(() -> {
lastVideoFrameMs = System.currentTimeMillis();
if (streamIdle) {
onStreamResumedAfterIdle();
}
streamMetrics.onVideoPacket(keyFrame, frameBytes);
if (videoDecoder != null && !reconfiguring) {
videoDecoder.queueFrame(ptsUs, keyFrame, data);
}
@@ -216,6 +241,8 @@ public class ReceiverCastService extends Service {
@Override
public void onAudioFrame(long ptsUs, byte[] data) {
final int frameBytes = data != null ? data.length : 0;
mainHandler.post(() -> streamMetrics.onAudioPacket(frameBytes));
if (Boolean.TRUE.equals(audioAccepted) && audioDecoder != null) {
audioDecoder.queueFrame(ptsUs, data);
}
@@ -229,6 +256,11 @@ public class ReceiverCastService extends Service {
@Override
public void onNetworkAdviceApplied() {
}
@Override
public void onPeerNetworkStats(NetworkStatsSnapshot stats) {
mainHandler.post(() -> streamMetrics.onPeerStats(stats));
}
});
session.start();
updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase()));
@@ -239,6 +271,9 @@ public class ReceiverCastService extends Service {
private void onSenderConnected(String senderName) {
phase = Phase.CONNECTED;
audioAccepted = null;
streamIdle = false;
streamMetrics.reset();
lastVideoFrameMs = 0;
updateStatus(getString(R.string.casting_from, senderName));
broadcastAuthenticated(senderName);
openPlaybackAwaiting(R.string.playback_awaiting_stream);
@@ -248,14 +283,50 @@ public class ReceiverCastService extends Service {
private void onSenderDisconnected() {
phase = Phase.LISTENING;
audioAccepted = null;
streamIdle = false;
lastVideoFrameMs = 0;
enterAwaitingMode(R.string.playback_stream_lost, true);
updateStatus(getString(R.string.waiting_sender));
Log.i(TAG, "Sender disconnected");
}
private void checkStreamIdle() {
if (phase != Phase.STREAMING || streamIdle || lastVideoFrameMs <= 0) {
return;
}
long idleMs = System.currentTimeMillis() - lastVideoFrameMs;
if (idleMs >= CastConfig.STREAM_IDLE_TIMEOUT_MS) {
Log.i(TAG, "Stream idle " + idleMs + "ms — awaiting");
enterAwaitingMode(R.string.playback_awaiting_stream, false);
}
}
/** Called when video resumes after idle on the same TCP session. */
private void onStreamResumedAfterIdle() {
if (!streamIdle) {
return;
}
streamIdle = false;
awaitingKeyframe = true;
openPlaybackAwaiting(R.string.playback_awaiting_stream);
scheduleDecoderConfigureRetries();
}
/** Stops decode, shows robot overlay; keeps TCP session if still connected. */
private void enterAwaitingMode(int messageResId, boolean fullReset) {
streamIdle = true;
hasPendingConfig = false;
awaitingKeyframe = true;
resetDecoderState();
detachSurfaceOnly();
broadcastDisconnected();
openPlaybackAwaiting(R.string.playback_stream_lost);
updateStatus(getString(R.string.waiting_sender));
Log.i(TAG, "Sender disconnected");
if (videoDecoder != null) {
videoDecoder.release();
videoDecoder = new VideoDecoder();
}
if (fullReset) {
streamMetrics.reset();
}
broadcastStreamIdle();
openPlaybackAwaiting(messageResId);
}
private void resetStreamState() {
@@ -320,8 +391,12 @@ public class ReceiverCastService extends Service {
return;
}
phase = Phase.STREAMING;
streamIdle = false;
streamMetrics.reset();
lastVideoFrameMs = 0;
reconfiguring = true;
awaitingKeyframe = true;
openPlaybackAwaiting(R.string.playback_awaiting_stream);
pendingWidth = size.width;
pendingHeight = size.height;
pendingCsd0 = csd0;
@@ -330,11 +405,18 @@ public class ReceiverCastService extends Service {
decoderActive = false;
mainHandler.postDelayed(() -> awaitingKeyframe = false, 2000);
notifyPlaybackDimensions(pendingWidth, pendingHeight);
tryConfigureDecoder();
scheduleDecoderConfigureRetries();
reconfiguring = false;
Log.i(TAG, "Stream config " + pendingWidth + "x" + pendingHeight);
}
private void scheduleDecoderConfigureRetries() {
tryConfigureDecoder();
mainHandler.postDelayed(this::tryConfigureDecoder, 250);
mainHandler.postDelayed(this::tryConfigureDecoder, 750);
mainHandler.postDelayed(this::tryConfigureDecoder, 2000);
}
/** Opens or refreshes fullscreen UI with robot overlay (listening / waiting for stream). */
private void openPlaybackAwaiting(int messageResId) {
playbackLaunched = true;
@@ -408,7 +490,17 @@ public class ReceiverCastService extends Service {
}
videoDecoder = new VideoDecoder();
videoDecoder.configure(pendingWidth, pendingHeight, pendingCsd0, pendingCsd1, attachedSurface,
this::onFirstFrameRendered);
new VideoDecoder.Listener() {
@Override
public void onFirstFrameRendered() {
ReceiverCastService.this.onFirstFrameRendered();
}
@Override
public void onFrameRendered() {
ReceiverCastService.this.onFrameRendered();
}
});
decoderActive = true;
decoderWidth = pendingWidth;
decoderHeight = pendingHeight;
@@ -424,18 +516,39 @@ public class ReceiverCastService extends Service {
private void onFirstFrameRendered() {
mainHandler.post(() -> {
streamMetrics.onRenderedFrame();
Log.i(TAG, "First frame on screen");
streamIdle = false;
broadcastVideoRendering();
});
}
private void onFrameRendered() {
mainHandler.post(() -> streamMetrics.onRenderedFrame());
}
private String buildDiagnosticsText() {
return streamMetrics.format(
settings.getTransport(),
pendingWidth,
pendingHeight,
decoderWidth,
decoderHeight,
streamIdle);
}
private void attachSurface(Surface surface) {
if (surface != null && surface.equals(attachedSurface) && decoderActive) {
Log.d(TAG, "Surface unchanged, skip reconfigure");
if (surface == null || !surface.isValid()) {
Log.w(TAG, "attachSurface: invalid surface");
return;
}
if (surface.equals(attachedSurface) && decoderActive) {
Log.d(TAG, "Surface unchanged, decoder active");
return;
}
attachedSurface = surface;
tryConfigureDecoder();
Log.i(TAG, "Surface attached, configuring decoder");
scheduleDecoderConfigureRetries();
}
private void detachSurfaceOnly() {
@@ -499,6 +612,17 @@ public class ReceiverCastService extends Service {
callbacks.finishBroadcast();
}
private void broadcastStreamIdle() {
int n = callbacks.beginBroadcast();
for (int i = 0; i < n; i++) {
try {
callbacks.getBroadcastItem(i).onStreamIdle();
} catch (RemoteException ignored) {
}
}
callbacks.finishBroadcast();
}
private void broadcastDisconnected() {
int n = callbacks.beginBroadcast();
for (int i = 0; i < n; i++) {
@@ -543,6 +667,7 @@ public class ReceiverCastService extends Service {
@Override
public void onDestroy() {
mainHandler.removeCallbacks(idleWatchdog);
releaseAll();
callbacks.kill();
super.onDestroy();

View File

@@ -5,19 +5,24 @@ import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.RemoteException;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.View;
import android.view.WindowManager;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.view.animation.AccelerateDecelerateInterpolator;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.ICastReceiverService;
import com.foxx.androidcast.ICastStatusCallback;
import com.foxx.androidcast.R;
@@ -37,14 +42,16 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
private AspectRatioFrameLayout aspectContainer;
private SurfaceView surfaceView;
private View awaitingOverlay;
private TextView diagnosticsText;
private ImageView robotView;
private TextView awaitingMessage;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private SurfaceHolder surfaceHolder;
private int videoWidth;
private int videoHeight;
private boolean streamRendering;
private boolean surfaceSentToService;
private Animation robotDance;
private AnimatorSet robotAnimator;
private ICastReceiverService receiverService;
private boolean bound;
@@ -68,6 +75,17 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
runOnUiThread(() -> {
streamRendering = true;
hideAwaitingOverlay();
updateDiagnosticsVisibility();
});
}
@Override
public void onStreamIdle() {
runOnUiThread(() -> {
streamRendering = false;
surfaceSentToService = false;
showAwaitingOverlay(R.string.playback_awaiting_stream);
updateDiagnosticsVisibility();
});
}
@@ -77,6 +95,14 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
}
};
private final Runnable diagnosticsRefresh = new Runnable() {
@Override
public void run() {
refreshDiagnostics();
mainHandler.postDelayed(this, 500);
}
};
private final ServiceConnection connection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
@@ -110,12 +136,12 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
aspectContainer = findViewById(R.id.aspect_container);
surfaceView = findViewById(R.id.surface_playback);
awaitingOverlay = findViewById(R.id.overlay_awaiting);
diagnosticsText = findViewById(R.id.text_diagnostics);
robotView = findViewById(R.id.image_robot);
awaitingMessage = findViewById(R.id.text_awaiting_message);
surfaceHolder = surfaceView.getHolder();
surfaceHolder.addCallback(this);
surfaceView.setZOrderMediaOverlay(false);
robotDance = AnimationUtils.loadAnimation(this, R.anim.robot_dance);
applyAspectRatio();
if (getIntent().getBooleanExtra(EXTRA_SHOW_AWAITING, true)) {
@@ -132,6 +158,9 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
return;
}
readSizeFromIntent(intent);
if (videoWidth > 0 && videoHeight > 0 && !streamRendering) {
surfaceSentToService = false;
}
updateVideoSize(videoWidth, videoHeight);
if (intent.getBooleanExtra(EXTRA_SHOW_AWAITING, false)) {
int msg = intent.getIntExtra(EXTRA_AWAITING_MESSAGE, R.string.playback_listening);
@@ -145,10 +174,13 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
if (awaitingOverlay != null && awaitingOverlay.getVisibility() == View.VISIBLE) {
startRobotAnimation();
}
mainHandler.post(diagnosticsRefresh);
updateDiagnosticsVisibility();
}
@Override
protected void onPause() {
mainHandler.removeCallbacks(diagnosticsRefresh);
stopRobotAnimation();
super.onPause();
}
@@ -199,9 +231,6 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
if (awaitingOverlay == null) {
return;
}
if (surfaceView != null) {
surfaceView.setVisibility(View.INVISIBLE);
}
awaitingOverlay.setVisibility(View.VISIBLE);
awaitingOverlay.bringToFront();
if (awaitingMessage != null) {
@@ -214,23 +243,66 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
if (awaitingOverlay != null) {
awaitingOverlay.setVisibility(View.GONE);
}
if (surfaceView != null) {
surfaceView.setVisibility(View.VISIBLE);
}
stopRobotAnimation();
updateDiagnosticsVisibility();
}
private void updateDiagnosticsVisibility() {
if (diagnosticsText == null) {
return;
}
boolean show = CastConfig.SHOW_CAST_DIAGNOSTICS && streamRendering;
diagnosticsText.setVisibility(show ? View.VISIBLE : View.GONE);
if (show) {
diagnosticsText.bringToFront();
}
}
private void refreshDiagnostics() {
if (diagnosticsText == null || !CastConfig.SHOW_CAST_DIAGNOSTICS || !bound || receiverService == null) {
return;
}
if (!streamRendering) {
return;
}
try {
String text = receiverService.getDiagnosticsText();
if (text != null) {
diagnosticsText.setText(text);
}
} catch (RemoteException ignored) {
}
}
private void startRobotAnimation() {
if (robotView == null || robotDance == null) {
if (robotView == null) {
return;
}
robotView.clearAnimation();
robotView.startAnimation(robotDance);
stopRobotAnimation();
ObjectAnimator bounce = ObjectAnimator.ofFloat(robotView, View.TRANSLATION_Y, 0f, -32f);
bounce.setDuration(500);
bounce.setInterpolator(new AccelerateDecelerateInterpolator());
bounce.setRepeatMode(ValueAnimator.REVERSE);
bounce.setRepeatCount(ValueAnimator.INFINITE);
ObjectAnimator wiggle = ObjectAnimator.ofFloat(robotView, View.ROTATION, -12f, 12f);
wiggle.setDuration(400);
wiggle.setInterpolator(new AccelerateDecelerateInterpolator());
wiggle.setRepeatMode(ValueAnimator.REVERSE);
wiggle.setRepeatCount(ValueAnimator.INFINITE);
robotAnimator = new AnimatorSet();
robotAnimator.playTogether(bounce, wiggle);
robotAnimator.start();
}
private void stopRobotAnimation() {
if (robotAnimator != null) {
robotAnimator.cancel();
robotAnimator = null;
}
if (robotView != null) {
robotView.clearAnimation();
robotView.animate().cancel();
robotView.setTranslationY(0f);
robotView.setRotation(0f);
}
}
@@ -272,6 +344,7 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
if (videoWidth > 0 && videoHeight > 0) {
holder.setFixedSize(videoWidth, videoHeight);
}
surfaceSentToService = false;
attachSurfaceIfReady();
}

View File

@@ -8,6 +8,8 @@ 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.NetworkStatsSnapshot;
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
@@ -34,6 +36,8 @@ public class ReceiverSession {
void onDisconnected();
void onNetworkAdviceApplied();
void onPeerNetworkStats(NetworkStatsSnapshot stats);
}
private final String pin;
@@ -94,6 +98,16 @@ public class ReceiverSession {
Log.i(TAG, "Connection closed");
postStatus("Sender disconnected");
}
} catch (IOException e) {
if (running.get()) {
if (isProtocolDesync(e)) {
Log.w(TAG, "Session desync (peer closed or corrupt stream): " + e.getMessage());
postStatus("Connection reset — waiting for sender…");
} else {
Log.e(TAG, "Session error", e);
postStatus(e.getMessage() != null ? e.getMessage() : "Session error");
}
}
} catch (Exception e) {
if (running.get()) {
Log.e(TAG, "Session error", e);
@@ -130,6 +144,12 @@ public class ReceiverSession {
}
if (networkFeedback != null && networkFeedback.handleMessage(msg)) {
listener.onNetworkAdviceApplied();
if (msg.type == CastProtocol.MSG_NETWORK_STATS) {
try {
listener.onPeerNetworkStats(CastProtocol.parseNetworkStats(msg.payload));
} catch (IOException ignored) {
}
}
continue;
}
switch (msg.type) {
@@ -139,6 +159,7 @@ public class ReceiverSession {
break;
case CastProtocol.MSG_VIDEO_FRAME:
CastProtocol.VideoFrame vf = CastProtocol.parseVideoFrame(msg.payload);
recordNetworkBytes(vf.data != null ? vf.data.length : 0);
listener.onVideoFrame(vf.ptsUs, vf.keyFrame, vf.data);
break;
case CastProtocol.MSG_AUDIO_CONFIG:
@@ -147,10 +168,11 @@ public class ReceiverSession {
break;
case CastProtocol.MSG_AUDIO_FRAME:
CastProtocol.AudioFrame af = CastProtocol.parseAudioFrame(msg.payload);
recordNetworkBytes(af.data != null ? af.data.length : 0);
listener.onAudioFrame(af.ptsUs, af.data);
break;
case CastProtocol.MSG_GOODBYE:
postStatus("Sender disconnected");
postStatus("Sender stopped casting");
return;
default:
break;
@@ -158,6 +180,23 @@ public class ReceiverSession {
}
}
private void recordNetworkBytes(int bytes) {
if (networkFeedback != null && networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) {
((PassThroughNetworkControlPlane) networkFeedback.getPlane()).recordBytes(bytes);
}
}
private static boolean isProtocolDesync(IOException e) {
String msg = e.getMessage();
if (msg == null) {
return false;
}
return msg.contains("Invalid payload length")
|| msg.contains("Invalid blob length")
|| msg.contains("Connection reset")
|| msg.contains("Broken pipe");
}
private void postStatus(String status) {
listener.onStatus(status);
}

View File

@@ -0,0 +1,162 @@
package com.foxx.androidcast.receiver;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
/** Receiver-side counters for diagnostics overlay and bandwidth estimates. */
public final class StreamMetrics {
private long videoPackets;
private long audioPackets;
private long keyFrames;
private long deltaFrames;
private long videoBytes;
private long audioBytes;
private long renderedFrames;
private long windowVideoBytes;
private long windowStartMs;
private long lastVideoPacketMs;
private long lastRenderedFrameMs;
private long firstVideoPacketMs;
private int peerRttMs;
private int peerRecvKbps;
private int peerSendKbps;
public synchronized void reset() {
videoPackets = 0;
audioPackets = 0;
keyFrames = 0;
deltaFrames = 0;
videoBytes = 0;
audioBytes = 0;
renderedFrames = 0;
windowVideoBytes = 0;
windowStartMs = 0;
lastVideoPacketMs = 0;
lastRenderedFrameMs = 0;
firstVideoPacketMs = 0;
peerRttMs = 0;
peerRecvKbps = 0;
peerSendKbps = 0;
}
public synchronized void onVideoPacket(boolean keyFrame, int bytes) {
long now = System.currentTimeMillis();
videoPackets++;
if (keyFrame) {
keyFrames++;
} else {
deltaFrames++;
}
videoBytes += bytes;
if (windowStartMs == 0) {
windowStartMs = now;
}
windowVideoBytes += bytes;
if (firstVideoPacketMs == 0) {
firstVideoPacketMs = now;
}
lastVideoPacketMs = now;
}
public synchronized void onAudioPacket(int bytes) {
audioPackets++;
audioBytes += bytes;
}
public synchronized void onRenderedFrame() {
renderedFrames++;
lastRenderedFrameMs = System.currentTimeMillis();
}
public synchronized void onPeerStats(NetworkStatsSnapshot peer) {
if (peer == null) {
return;
}
peerRttMs = peer.rttMs;
peerRecvKbps = peer.recvBitrateKbps;
peerSendKbps = peer.sendBitrateKbps;
}
public synchronized long getLastVideoPacketMs() {
return lastVideoPacketMs;
}
public synchronized String format(String transport, int streamW, int streamH, int renderW, int renderH,
boolean idle) {
long now = System.currentTimeMillis();
float inFps = computeFps(videoPackets, firstVideoPacketMs, now);
float renderFps = computeFps(renderedFrames, firstVideoPacketMs, lastRenderedFrameMs);
int localKbps = computeLocalKbps(now);
long idleMs = lastVideoPacketMs > 0 ? now - lastVideoPacketMs : -1;
StringBuilder sb = new StringBuilder();
sb.append("Transport: ").append(transport != null ? transport.toUpperCase() : "?").append('\n');
sb.append("Stream: ").append(streamW).append('x').append(streamH);
if (renderW > 0 && renderH > 0 && (renderW != streamW || renderH != streamH)) {
sb.append(" render: ").append(renderW).append('x').append(renderH);
}
sb.append('\n');
sb.append("In FPS: ").append(formatFloat(inFps));
sb.append(" Render FPS: ").append(formatFloat(renderFps)).append('\n');
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("Video pkts: ").append(videoPackets);
sb.append(" (IDR ").append(keyFrames).append(" / P ").append(deltaFrames).append(")\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');
sb.append("State: ").append(idle ? "idle (awaiting)" : "streaming");
if (idleMs >= 0) {
sb.append(" last frame: ").append(idleMs).append(" ms ago");
}
return sb.toString();
}
private int computeLocalKbps(long now) {
if (windowStartMs == 0 || windowVideoBytes == 0) {
return peerRecvKbps;
}
long elapsed = now - windowStartMs;
if (elapsed < 200) {
return peerRecvKbps;
}
int kbps = (int) (windowVideoBytes * 8L / elapsed);
if (elapsed > 4000) {
windowStartMs = now;
windowVideoBytes = 0;
}
return kbps;
}
private static float computeFps(long count, long startMs, long endMs) {
if (count <= 1 || startMs <= 0) {
return 0f;
}
long end = endMs > startMs ? endMs : System.currentTimeMillis();
float sec = (end - startMs) / 1000f;
if (sec < 0.05f) {
return 0f;
}
return count / sec;
}
private static String formatFloat(float v) {
if (v <= 0f) {
return "n/a";
}
return String.format(java.util.Locale.US, "%.1f", v);
}
private static String formatBytes(long bytes) {
if (bytes < 1024) {
return bytes + " B";
}
if (bytes < 1024 * 1024) {
return String.format(java.util.Locale.US, "%.1f KB", bytes / 1024f);
}
return String.format(java.util.Locale.US, "%.1f MB", bytes / (1024f * 1024f));
}
}

View File

@@ -19,6 +19,9 @@ public class VideoDecoder {
public interface Listener {
void onFirstFrameRendered();
/** Called for each frame after the first. */
default void onFrameRendered() {}
}
private final Object lock = new Object();
@@ -154,13 +157,17 @@ public class VideoDecoder {
}
private void notifyFirstFrameLocked() {
if (firstFrameNotified || listener == null) {
if (listener == null) {
return;
}
firstFrameNotified = true;
Listener cb = listener;
Log.i(TAG, "First frame rendered");
cb.onFirstFrameRendered();
if (!firstFrameNotified) {
firstFrameNotified = true;
Log.i(TAG, "First frame rendered");
cb.onFirstFrameRendered();
} else {
cb.onFrameRendered();
}
}
private static final class PendingFrame {

View File

@@ -24,6 +24,12 @@ public class DeviceListAdapter extends BaseAdapter {
inflater = LayoutInflater.from(context);
}
public void clearDevices() {
devices.clear();
selectedPosition = -1;
notifyDataSetChanged();
}
public void setDevices(List<DiscoveryManager.DiscoveredDevice> list) {
devices.clear();
if (list != null) {

View File

@@ -155,7 +155,7 @@ public class ScreenCastService extends Service {
updateStatus(getString(R.string.connecting_to, host));
session = new CastSession(CastTransportFactory.create(settings));
updateStatus(getString(R.string.waiting_receiver, host, port));
connectWithRetry(host, port);
connectWithRetry(host, port, settings);
updateStatus(getString(R.string.authenticating));
session.clientHandshake(deviceName != null ? deviceName : "Sender", pin, settings);
networkFeedback = new NetworkFeedbackManager(session, true);
@@ -202,15 +202,23 @@ public class ScreenCastService extends Service {
}
}
private void connectWithRetry(String host, int port) throws IOException {
private void connectWithRetry(String host, int port, CastSettings settings) throws IOException {
IOException last = null;
for (int i = 0; i < CONNECT_RETRIES; i++) {
try {
if (session != null) {
session.close();
}
session = new CastSession(CastTransportFactory.create(settings));
session.connect(host, port);
return;
} catch (IOException e) {
last = e;
Log.w(TAG, "Connect attempt " + (i + 1) + " failed", e);
if (session != null) {
session.close();
session = null;
}
try {
Thread.sleep(CONNECT_RETRY_MS);
} catch (InterruptedException ie) {

View File

@@ -89,8 +89,11 @@ public class SenderActivity extends AppCompatActivity {
}
private void restartDiscovery() {
deviceAdapter.clearDevices();
statusText.setText(R.string.searching);
if (discovery != null) {
discovery.stop();
discovery.clearDevices();
}
discovery.startBrowsing();
}

View File

@@ -22,4 +22,9 @@
layout="@layout/overlay_stream_awaiting"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<include
layout="@layout/overlay_cast_diagnostics"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/text_diagnostics"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|start"
android:layout_margin="12dp"
android:background="#99000000"
android:elevation="20dp"
android:fontFamily="monospace"
android:lineSpacingExtra="2dp"
android:padding="10dp"
android:textColor="#E0FFE0"
android:textSize="11sp"
android:visibility="gone" />

View File

@@ -3,7 +3,7 @@
android:id="@+id/overlay_awaiting"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#CC000000"
android:background="#FF000000"
android:clickable="true"
android:elevation="16dp"
android:focusable="true">