mirror of
git://f0xx.org/android_cast
synced 2026-07-29 06:18:42 +03:00
sync
This commit is contained in:
@@ -10,4 +10,5 @@ interface ICastReceiverService {
|
||||
void attachVideoSurface(in Surface surface);
|
||||
void detachVideoSurface();
|
||||
void stopReceiving();
|
||||
String getDiagnosticsText();
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@ interface ICastStatusCallback {
|
||||
void onAuthenticated(String senderName);
|
||||
void onStreamStarted(int width, int height);
|
||||
void onVideoRendering();
|
||||
void onStreamIdle();
|
||||
void onDisconnected();
|
||||
}
|
||||
|
||||
@@ -19,5 +19,11 @@ public final class CastConfig {
|
||||
/** Max UDP payload chunk (fits typical Wi‑Fi 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() {}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
15
app/src/main/res/layout/overlay_cast_diagnostics.xml
Normal file
15
app/src/main/res/layout/overlay_cast_diagnostics.xml
Normal 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" />
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user