From 17fddbb9059b022b3d38db70e4b020049b6781e5 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Fri, 15 May 2026 18:17:39 +0200 Subject: [PATCH] sync --- .../androidcast/ICastReceiverService.aidl | 1 + .../foxx/androidcast/ICastStatusCallback.aidl | 1 + .../java/com/foxx/androidcast/CastConfig.java | 6 + .../discovery/DiscoveryManager.java | 6 + .../androidcast/network/CastProtocol.java | 4 +- .../androidcast/network/TcpCastTransport.java | 35 +++- .../PassThroughNetworkControlPlane.java | 37 +++- .../receiver/ReceiverActivity.java | 5 + .../receiver/ReceiverCastService.java | 145 ++++++++++++++-- .../receiver/ReceiverPlaybackActivity.java | 101 +++++++++-- .../androidcast/receiver/ReceiverSession.java | 41 ++++- .../androidcast/receiver/StreamMetrics.java | 162 ++++++++++++++++++ .../androidcast/receiver/VideoDecoder.java | 15 +- .../androidcast/sender/DeviceListAdapter.java | 6 + .../androidcast/sender/ScreenCastService.java | 12 +- .../androidcast/sender/SenderActivity.java | 3 + app/src/main/res/layout/activity_playback.xml | 5 + .../res/layout/overlay_cast_diagnostics.xml | 15 ++ .../res/layout/overlay_stream_awaiting.xml | 2 +- 19 files changed, 562 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/StreamMetrics.java create mode 100644 app/src/main/res/layout/overlay_cast_diagnostics.xml diff --git a/app/src/main/aidl/com/foxx/androidcast/ICastReceiverService.aidl b/app/src/main/aidl/com/foxx/androidcast/ICastReceiverService.aidl index faed022..888362a 100644 --- a/app/src/main/aidl/com/foxx/androidcast/ICastReceiverService.aidl +++ b/app/src/main/aidl/com/foxx/androidcast/ICastReceiverService.aidl @@ -10,4 +10,5 @@ interface ICastReceiverService { void attachVideoSurface(in Surface surface); void detachVideoSurface(); void stopReceiving(); + String getDiagnosticsText(); } diff --git a/app/src/main/aidl/com/foxx/androidcast/ICastStatusCallback.aidl b/app/src/main/aidl/com/foxx/androidcast/ICastStatusCallback.aidl index 724f62f..225694d 100644 --- a/app/src/main/aidl/com/foxx/androidcast/ICastStatusCallback.aidl +++ b/app/src/main/aidl/com/foxx/androidcast/ICastStatusCallback.aidl @@ -5,5 +5,6 @@ interface ICastStatusCallback { void onAuthenticated(String senderName); void onStreamStarted(int width, int height); void onVideoRendering(); + void onStreamIdle(); void onDisconnected(); } diff --git a/app/src/main/java/com/foxx/androidcast/CastConfig.java b/app/src/main/java/com/foxx/androidcast/CastConfig.java index 3591e4e..020446d 100644 --- a/app/src/main/java/com/foxx/androidcast/CastConfig.java +++ b/app/src/main/java/com/foxx/androidcast/CastConfig.java @@ -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() {} } diff --git a/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java b/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java index 4603780..b381a9d 100644 --- a/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java +++ b/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java @@ -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; diff --git a/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java index f07e2c5..0c02046 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java @@ -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); diff --git a/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java b/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java index 85b5c83..c953e53 100644 --- a/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java +++ b/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java @@ -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; } 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 5d16b99..dab55a2 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 @@ -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) { diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java index 19135b6..4df6d87 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java @@ -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)); 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 0588a46..1d0331d 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java @@ -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(); diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java index d78b5a9..68c913d 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java @@ -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(); } diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java index 1a1f266..13b62d3 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java @@ -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); } diff --git a/app/src/main/java/com/foxx/androidcast/receiver/StreamMetrics.java b/app/src/main/java/com/foxx/androidcast/receiver/StreamMetrics.java new file mode 100644 index 0000000..0ee8dd5 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/StreamMetrics.java @@ -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)); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java b/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java index 0451380..02d8579 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java @@ -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 { diff --git a/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java b/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java index 3261bd0..14e2243 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java +++ b/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java @@ -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 list) { devices.clear(); if (list != null) { 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 9f6c319..1d2754b 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java +++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java @@ -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) { diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java index 8719e6f..10b6e4b 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java +++ b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java @@ -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(); } diff --git a/app/src/main/res/layout/activity_playback.xml b/app/src/main/res/layout/activity_playback.xml index e39cc80..eb7beac 100644 --- a/app/src/main/res/layout/activity_playback.xml +++ b/app/src/main/res/layout/activity_playback.xml @@ -22,4 +22,9 @@ layout="@layout/overlay_stream_awaiting" android:layout_width="match_parent" android:layout_height="match_parent" /> + + diff --git a/app/src/main/res/layout/overlay_cast_diagnostics.xml b/app/src/main/res/layout/overlay_cast_diagnostics.xml new file mode 100644 index 0000000..304322d --- /dev/null +++ b/app/src/main/res/layout/overlay_cast_diagnostics.xml @@ -0,0 +1,15 @@ + + diff --git a/app/src/main/res/layout/overlay_stream_awaiting.xml b/app/src/main/res/layout/overlay_stream_awaiting.xml index ce7b1a7..b66355a 100644 --- a/app/src/main/res/layout/overlay_stream_awaiting.xml +++ b/app/src/main/res/layout/overlay_stream_awaiting.xml @@ -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">