mirror of
git://f0xx.org/android_cast
synced 2026-07-29 08:37:37 +03:00
sync
This commit is contained in:
@@ -10,4 +10,5 @@ interface ICastReceiverService {
|
|||||||
void attachVideoSurface(in Surface surface);
|
void attachVideoSurface(in Surface surface);
|
||||||
void detachVideoSurface();
|
void detachVideoSurface();
|
||||||
void stopReceiving();
|
void stopReceiving();
|
||||||
|
String getDiagnosticsText();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ interface ICastStatusCallback {
|
|||||||
void onAuthenticated(String senderName);
|
void onAuthenticated(String senderName);
|
||||||
void onStreamStarted(int width, int height);
|
void onStreamStarted(int width, int height);
|
||||||
void onVideoRendering();
|
void onVideoRendering();
|
||||||
|
void onStreamIdle();
|
||||||
void onDisconnected();
|
void onDisconnected();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,5 +19,11 @@ public final class CastConfig {
|
|||||||
/** Max UDP payload chunk (fits typical Wi‑Fi MTU with header). */
|
/** Max UDP payload chunk (fits typical Wi‑Fi MTU with header). */
|
||||||
public static final int UDP_CHUNK_SIZE = 1200;
|
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() {}
|
private CastConfig() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ public class DiscoveryManager {
|
|||||||
devices.clear();
|
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) {
|
public void startAnnouncing(String deviceName, int port, String transport) {
|
||||||
if (!running.compareAndSet(false, true)) {
|
if (!running.compareAndSet(false, true)) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -244,8 +244,8 @@ public final class CastProtocol {
|
|||||||
if (len == 0) {
|
if (len == 0) {
|
||||||
return new byte[0];
|
return new byte[0];
|
||||||
}
|
}
|
||||||
if (len < 0 || len > 512 * 1024) {
|
if (len < 0 || len > 2 * 1024 * 1024) {
|
||||||
throw new IOException("Invalid blob length");
|
throw new IOException("Invalid blob length: " + len);
|
||||||
}
|
}
|
||||||
byte[] buf = new byte[len];
|
byte[] buf = new byte[len];
|
||||||
dis.readFully(buf);
|
dis.readFully(buf);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.foxx.androidcast.network;
|
package com.foxx.androidcast.network;
|
||||||
|
|
||||||
|
import java.io.BufferedInputStream;
|
||||||
|
import java.io.BufferedOutputStream;
|
||||||
import java.io.DataInputStream;
|
import java.io.DataInputStream;
|
||||||
import java.io.DataOutputStream;
|
import java.io.DataOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -12,12 +14,14 @@ import java.net.SocketTimeoutException;
|
|||||||
/** Reliable message transport over TCP (serialized send/receive). */
|
/** Reliable message transport over TCP (serialized send/receive). */
|
||||||
public class TcpCastTransport implements CastTransport {
|
public class TcpCastTransport implements CastTransport {
|
||||||
private static final int CONNECT_TIMEOUT_MS = 10_000;
|
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 sendLock = new Object();
|
||||||
private final Object receiveLock = new Object();
|
private final Object receiveLock = new Object();
|
||||||
|
|
||||||
private ServerSocket serverSocket;
|
private ServerSocket serverSocket;
|
||||||
private Socket socket;
|
private Socket socket;
|
||||||
|
private BufferedInputStream bufferedIn;
|
||||||
private DataInputStream in;
|
private DataInputStream in;
|
||||||
private DataOutputStream out;
|
private DataOutputStream out;
|
||||||
|
|
||||||
@@ -46,8 +50,9 @@ public class TcpCastTransport implements CastTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void openStreams() throws IOException {
|
private void openStreams() throws IOException {
|
||||||
in = new DataInputStream(socket.getInputStream());
|
bufferedIn = new BufferedInputStream(socket.getInputStream(), 256 * 1024);
|
||||||
out = new DataOutputStream(socket.getOutputStream());
|
in = new DataInputStream(bufferedIn);
|
||||||
|
out = new DataOutputStream(new BufferedOutputStream(socket.getOutputStream()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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
|
@Override
|
||||||
public CastProtocol.Message receive(int timeoutMs) throws IOException {
|
public CastProtocol.Message receive(int timeoutMs) throws IOException {
|
||||||
synchronized (receiveLock) {
|
synchronized (receiveLock) {
|
||||||
if (socket == null || socket.isClosed() || in == null) {
|
if (socket == null || socket.isClosed() || in == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (timeoutMs > 0) {
|
if (timeoutMs > 0 && !waitForHeader(timeoutMs)) {
|
||||||
socket.setSoTimeout(timeoutMs);
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
socket.setSoTimeout(0);
|
||||||
return CastProtocol.readMessage(in);
|
return CastProtocol.readMessage(in);
|
||||||
} catch (SocketTimeoutException e) {
|
} catch (SocketTimeoutException e) {
|
||||||
return null;
|
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
|
@Override
|
||||||
public String getModeLabel() {
|
public String getModeLabel() {
|
||||||
return "TCP";
|
return "TCP";
|
||||||
@@ -113,6 +139,7 @@ public class TcpCastTransport implements CastTransport {
|
|||||||
}
|
}
|
||||||
socket = null;
|
socket = null;
|
||||||
serverSocket = null;
|
serverSocket = null;
|
||||||
|
bufferedIn = null;
|
||||||
in = null;
|
in = null;
|
||||||
out = null;
|
out = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
|
|||||||
private long lastStatsMs;
|
private long lastStatsMs;
|
||||||
private NetworkStatsSnapshot lastPeer;
|
private NetworkStatsSnapshot lastPeer;
|
||||||
private final NetworkControlAdvice outbound = NetworkControlAdvice.none();
|
private final NetworkControlAdvice outbound = NetworkControlAdvice.none();
|
||||||
|
private long statsWindowBytes;
|
||||||
|
private long statsWindowStartMs;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public NetworkControlAdvice tick(long nowMs) {
|
public NetworkControlAdvice tick(long nowMs) {
|
||||||
@@ -30,7 +32,22 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onPeerStats(NetworkStatsSnapshot stats) {
|
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
|
@Override
|
||||||
@@ -41,12 +58,28 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
|
|||||||
s.rttMs = lastPeer != null ? lastPeer.rttMs : 0;
|
s.rttMs = lastPeer != null ? lastPeer.rttMs : 0;
|
||||||
s.lossRatio = 0f;
|
s.lossRatio = 0f;
|
||||||
s.sendBitrateKbps = 0;
|
s.sendBitrateKbps = 0;
|
||||||
s.recvBitrateKbps = 0;
|
s.recvBitrateKbps = estimateRecvKbps(nowMs);
|
||||||
s.congestionLevel = 0;
|
s.congestionLevel = 0;
|
||||||
s.jitterMs = 0;
|
s.jitterMs = 0;
|
||||||
return s;
|
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
|
@Override
|
||||||
public void onPeerControl(int suggestedBitrateKbps, String suggestedTransport,
|
public void onPeerControl(int suggestedBitrateKbps, String suggestedTransport,
|
||||||
boolean switchTransport, int congestionLevel) {
|
boolean switchTransport, int congestionLevel) {
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ public class ReceiverActivity extends AppCompatActivity {
|
|||||||
runOnUiThread(() -> statusText.setText(R.string.playback_rendering));
|
runOnUiThread(() -> statusText.setText(R.string.playback_rendering));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onStreamIdle() {
|
||||||
|
runOnUiThread(() -> statusText.setText(R.string.playback_awaiting_stream));
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDisconnected() {
|
public void onDisconnected() {
|
||||||
runOnUiThread(() -> statusText.setText(R.string.waiting_sender));
|
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.ICastStatusCallback;
|
||||||
import com.foxx.androidcast.IntentExtras;
|
import com.foxx.androidcast.IntentExtras;
|
||||||
import com.foxx.androidcast.R;
|
import com.foxx.androidcast.R;
|
||||||
|
import com.foxx.androidcast.CastConfig;
|
||||||
import com.foxx.androidcast.discovery.DiscoveryManager;
|
import com.foxx.androidcast.discovery.DiscoveryManager;
|
||||||
|
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
|
||||||
@@ -71,6 +73,17 @@ public class ReceiverCastService extends Service {
|
|||||||
private byte[] pendingAudioCsd;
|
private byte[] pendingAudioCsd;
|
||||||
private PowerManager.WakeLock wakeLock;
|
private PowerManager.WakeLock wakeLock;
|
||||||
private String activePin = com.foxx.androidcast.CastConfig.DEFAULT_PIN;
|
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() {
|
private final ICastReceiverService.Stub binder = new ICastReceiverService.Stub() {
|
||||||
@Override
|
@Override
|
||||||
@@ -107,12 +120,18 @@ public class ReceiverCastService extends Service {
|
|||||||
public void stopReceiving() {
|
public void stopReceiving() {
|
||||||
stopSelfSafely();
|
stopSelfSafely();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getDiagnosticsText() {
|
||||||
|
return buildDiagnosticsText();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreate() {
|
public void onCreate() {
|
||||||
super.onCreate();
|
super.onCreate();
|
||||||
CastNotifications.createChannels(this);
|
CastNotifications.createChannels(this);
|
||||||
|
mainHandler.post(idleWatchdog);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -188,7 +207,13 @@ public class ReceiverCastService extends Service {
|
|||||||
if (keyFrame) {
|
if (keyFrame) {
|
||||||
awaitingKeyframe = false;
|
awaitingKeyframe = false;
|
||||||
}
|
}
|
||||||
|
final int frameBytes = data != null ? data.length : 0;
|
||||||
mainHandler.post(() -> {
|
mainHandler.post(() -> {
|
||||||
|
lastVideoFrameMs = System.currentTimeMillis();
|
||||||
|
if (streamIdle) {
|
||||||
|
onStreamResumedAfterIdle();
|
||||||
|
}
|
||||||
|
streamMetrics.onVideoPacket(keyFrame, frameBytes);
|
||||||
if (videoDecoder != null && !reconfiguring) {
|
if (videoDecoder != null && !reconfiguring) {
|
||||||
videoDecoder.queueFrame(ptsUs, keyFrame, data);
|
videoDecoder.queueFrame(ptsUs, keyFrame, data);
|
||||||
}
|
}
|
||||||
@@ -216,6 +241,8 @@ public class ReceiverCastService extends Service {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onAudioFrame(long ptsUs, byte[] data) {
|
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) {
|
if (Boolean.TRUE.equals(audioAccepted) && audioDecoder != null) {
|
||||||
audioDecoder.queueFrame(ptsUs, data);
|
audioDecoder.queueFrame(ptsUs, data);
|
||||||
}
|
}
|
||||||
@@ -229,6 +256,11 @@ public class ReceiverCastService extends Service {
|
|||||||
@Override
|
@Override
|
||||||
public void onNetworkAdviceApplied() {
|
public void onNetworkAdviceApplied() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPeerNetworkStats(NetworkStatsSnapshot stats) {
|
||||||
|
mainHandler.post(() -> streamMetrics.onPeerStats(stats));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
session.start();
|
session.start();
|
||||||
updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase()));
|
updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase()));
|
||||||
@@ -239,6 +271,9 @@ public class ReceiverCastService extends Service {
|
|||||||
private void onSenderConnected(String senderName) {
|
private void onSenderConnected(String senderName) {
|
||||||
phase = Phase.CONNECTED;
|
phase = Phase.CONNECTED;
|
||||||
audioAccepted = null;
|
audioAccepted = null;
|
||||||
|
streamIdle = false;
|
||||||
|
streamMetrics.reset();
|
||||||
|
lastVideoFrameMs = 0;
|
||||||
updateStatus(getString(R.string.casting_from, senderName));
|
updateStatus(getString(R.string.casting_from, senderName));
|
||||||
broadcastAuthenticated(senderName);
|
broadcastAuthenticated(senderName);
|
||||||
openPlaybackAwaiting(R.string.playback_awaiting_stream);
|
openPlaybackAwaiting(R.string.playback_awaiting_stream);
|
||||||
@@ -248,14 +283,50 @@ public class ReceiverCastService extends Service {
|
|||||||
private void onSenderDisconnected() {
|
private void onSenderDisconnected() {
|
||||||
phase = Phase.LISTENING;
|
phase = Phase.LISTENING;
|
||||||
audioAccepted = null;
|
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;
|
hasPendingConfig = false;
|
||||||
awaitingKeyframe = true;
|
awaitingKeyframe = true;
|
||||||
resetDecoderState();
|
resetDecoderState();
|
||||||
detachSurfaceOnly();
|
if (videoDecoder != null) {
|
||||||
broadcastDisconnected();
|
videoDecoder.release();
|
||||||
openPlaybackAwaiting(R.string.playback_stream_lost);
|
videoDecoder = new VideoDecoder();
|
||||||
updateStatus(getString(R.string.waiting_sender));
|
}
|
||||||
Log.i(TAG, "Sender disconnected");
|
if (fullReset) {
|
||||||
|
streamMetrics.reset();
|
||||||
|
}
|
||||||
|
broadcastStreamIdle();
|
||||||
|
openPlaybackAwaiting(messageResId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void resetStreamState() {
|
private void resetStreamState() {
|
||||||
@@ -320,8 +391,12 @@ public class ReceiverCastService extends Service {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
phase = Phase.STREAMING;
|
phase = Phase.STREAMING;
|
||||||
|
streamIdle = false;
|
||||||
|
streamMetrics.reset();
|
||||||
|
lastVideoFrameMs = 0;
|
||||||
reconfiguring = true;
|
reconfiguring = true;
|
||||||
awaitingKeyframe = true;
|
awaitingKeyframe = true;
|
||||||
|
openPlaybackAwaiting(R.string.playback_awaiting_stream);
|
||||||
pendingWidth = size.width;
|
pendingWidth = size.width;
|
||||||
pendingHeight = size.height;
|
pendingHeight = size.height;
|
||||||
pendingCsd0 = csd0;
|
pendingCsd0 = csd0;
|
||||||
@@ -330,11 +405,18 @@ public class ReceiverCastService extends Service {
|
|||||||
decoderActive = false;
|
decoderActive = false;
|
||||||
mainHandler.postDelayed(() -> awaitingKeyframe = false, 2000);
|
mainHandler.postDelayed(() -> awaitingKeyframe = false, 2000);
|
||||||
notifyPlaybackDimensions(pendingWidth, pendingHeight);
|
notifyPlaybackDimensions(pendingWidth, pendingHeight);
|
||||||
tryConfigureDecoder();
|
scheduleDecoderConfigureRetries();
|
||||||
reconfiguring = false;
|
reconfiguring = false;
|
||||||
Log.i(TAG, "Stream config " + pendingWidth + "x" + pendingHeight);
|
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). */
|
/** Opens or refreshes fullscreen UI with robot overlay (listening / waiting for stream). */
|
||||||
private void openPlaybackAwaiting(int messageResId) {
|
private void openPlaybackAwaiting(int messageResId) {
|
||||||
playbackLaunched = true;
|
playbackLaunched = true;
|
||||||
@@ -408,7 +490,17 @@ public class ReceiverCastService extends Service {
|
|||||||
}
|
}
|
||||||
videoDecoder = new VideoDecoder();
|
videoDecoder = new VideoDecoder();
|
||||||
videoDecoder.configure(pendingWidth, pendingHeight, pendingCsd0, pendingCsd1, attachedSurface,
|
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;
|
decoderActive = true;
|
||||||
decoderWidth = pendingWidth;
|
decoderWidth = pendingWidth;
|
||||||
decoderHeight = pendingHeight;
|
decoderHeight = pendingHeight;
|
||||||
@@ -424,18 +516,39 @@ public class ReceiverCastService extends Service {
|
|||||||
|
|
||||||
private void onFirstFrameRendered() {
|
private void onFirstFrameRendered() {
|
||||||
mainHandler.post(() -> {
|
mainHandler.post(() -> {
|
||||||
|
streamMetrics.onRenderedFrame();
|
||||||
Log.i(TAG, "First frame on screen");
|
Log.i(TAG, "First frame on screen");
|
||||||
|
streamIdle = false;
|
||||||
broadcastVideoRendering();
|
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) {
|
private void attachSurface(Surface surface) {
|
||||||
if (surface != null && surface.equals(attachedSurface) && decoderActive) {
|
if (surface == null || !surface.isValid()) {
|
||||||
Log.d(TAG, "Surface unchanged, skip reconfigure");
|
Log.w(TAG, "attachSurface: invalid surface");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (surface.equals(attachedSurface) && decoderActive) {
|
||||||
|
Log.d(TAG, "Surface unchanged, decoder active");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
attachedSurface = surface;
|
attachedSurface = surface;
|
||||||
tryConfigureDecoder();
|
Log.i(TAG, "Surface attached, configuring decoder");
|
||||||
|
scheduleDecoderConfigureRetries();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void detachSurfaceOnly() {
|
private void detachSurfaceOnly() {
|
||||||
@@ -499,6 +612,17 @@ public class ReceiverCastService extends Service {
|
|||||||
callbacks.finishBroadcast();
|
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() {
|
private void broadcastDisconnected() {
|
||||||
int n = callbacks.beginBroadcast();
|
int n = callbacks.beginBroadcast();
|
||||||
for (int i = 0; i < n; i++) {
|
for (int i = 0; i < n; i++) {
|
||||||
@@ -543,6 +667,7 @@ public class ReceiverCastService extends Service {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDestroy() {
|
public void onDestroy() {
|
||||||
|
mainHandler.removeCallbacks(idleWatchdog);
|
||||||
releaseAll();
|
releaseAll();
|
||||||
callbacks.kill();
|
callbacks.kill();
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
|
|||||||
@@ -5,19 +5,24 @@ import android.content.Context;
|
|||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.ServiceConnection;
|
import android.content.ServiceConnection;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.os.Handler;
|
||||||
import android.os.IBinder;
|
import android.os.IBinder;
|
||||||
|
import android.os.Looper;
|
||||||
import android.os.RemoteException;
|
import android.os.RemoteException;
|
||||||
import android.view.SurfaceHolder;
|
import android.view.SurfaceHolder;
|
||||||
import android.view.SurfaceView;
|
import android.view.SurfaceView;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.WindowManager;
|
import android.view.WindowManager;
|
||||||
import android.view.animation.Animation;
|
import android.animation.AnimatorSet;
|
||||||
import android.view.animation.AnimationUtils;
|
import android.animation.ObjectAnimator;
|
||||||
|
import android.animation.ValueAnimator;
|
||||||
|
import android.view.animation.AccelerateDecelerateInterpolator;
|
||||||
import android.widget.ImageView;
|
import android.widget.ImageView;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.CastConfig;
|
||||||
import com.foxx.androidcast.ICastReceiverService;
|
import com.foxx.androidcast.ICastReceiverService;
|
||||||
import com.foxx.androidcast.ICastStatusCallback;
|
import com.foxx.androidcast.ICastStatusCallback;
|
||||||
import com.foxx.androidcast.R;
|
import com.foxx.androidcast.R;
|
||||||
@@ -37,14 +42,16 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
|||||||
private AspectRatioFrameLayout aspectContainer;
|
private AspectRatioFrameLayout aspectContainer;
|
||||||
private SurfaceView surfaceView;
|
private SurfaceView surfaceView;
|
||||||
private View awaitingOverlay;
|
private View awaitingOverlay;
|
||||||
|
private TextView diagnosticsText;
|
||||||
private ImageView robotView;
|
private ImageView robotView;
|
||||||
private TextView awaitingMessage;
|
private TextView awaitingMessage;
|
||||||
|
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||||
private SurfaceHolder surfaceHolder;
|
private SurfaceHolder surfaceHolder;
|
||||||
private int videoWidth;
|
private int videoWidth;
|
||||||
private int videoHeight;
|
private int videoHeight;
|
||||||
private boolean streamRendering;
|
private boolean streamRendering;
|
||||||
private boolean surfaceSentToService;
|
private boolean surfaceSentToService;
|
||||||
private Animation robotDance;
|
private AnimatorSet robotAnimator;
|
||||||
|
|
||||||
private ICastReceiverService receiverService;
|
private ICastReceiverService receiverService;
|
||||||
private boolean bound;
|
private boolean bound;
|
||||||
@@ -68,6 +75,17 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
|||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
streamRendering = true;
|
streamRendering = true;
|
||||||
hideAwaitingOverlay();
|
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() {
|
private final ServiceConnection connection = new ServiceConnection() {
|
||||||
@Override
|
@Override
|
||||||
public void onServiceConnected(ComponentName name, IBinder service) {
|
public void onServiceConnected(ComponentName name, IBinder service) {
|
||||||
@@ -110,12 +136,12 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
|||||||
aspectContainer = findViewById(R.id.aspect_container);
|
aspectContainer = findViewById(R.id.aspect_container);
|
||||||
surfaceView = findViewById(R.id.surface_playback);
|
surfaceView = findViewById(R.id.surface_playback);
|
||||||
awaitingOverlay = findViewById(R.id.overlay_awaiting);
|
awaitingOverlay = findViewById(R.id.overlay_awaiting);
|
||||||
|
diagnosticsText = findViewById(R.id.text_diagnostics);
|
||||||
robotView = findViewById(R.id.image_robot);
|
robotView = findViewById(R.id.image_robot);
|
||||||
awaitingMessage = findViewById(R.id.text_awaiting_message);
|
awaitingMessage = findViewById(R.id.text_awaiting_message);
|
||||||
surfaceHolder = surfaceView.getHolder();
|
surfaceHolder = surfaceView.getHolder();
|
||||||
surfaceHolder.addCallback(this);
|
surfaceHolder.addCallback(this);
|
||||||
surfaceView.setZOrderMediaOverlay(false);
|
surfaceView.setZOrderMediaOverlay(false);
|
||||||
robotDance = AnimationUtils.loadAnimation(this, R.anim.robot_dance);
|
|
||||||
applyAspectRatio();
|
applyAspectRatio();
|
||||||
|
|
||||||
if (getIntent().getBooleanExtra(EXTRA_SHOW_AWAITING, true)) {
|
if (getIntent().getBooleanExtra(EXTRA_SHOW_AWAITING, true)) {
|
||||||
@@ -132,6 +158,9 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
readSizeFromIntent(intent);
|
readSizeFromIntent(intent);
|
||||||
|
if (videoWidth > 0 && videoHeight > 0 && !streamRendering) {
|
||||||
|
surfaceSentToService = false;
|
||||||
|
}
|
||||||
updateVideoSize(videoWidth, videoHeight);
|
updateVideoSize(videoWidth, videoHeight);
|
||||||
if (intent.getBooleanExtra(EXTRA_SHOW_AWAITING, false)) {
|
if (intent.getBooleanExtra(EXTRA_SHOW_AWAITING, false)) {
|
||||||
int msg = intent.getIntExtra(EXTRA_AWAITING_MESSAGE, R.string.playback_listening);
|
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) {
|
if (awaitingOverlay != null && awaitingOverlay.getVisibility() == View.VISIBLE) {
|
||||||
startRobotAnimation();
|
startRobotAnimation();
|
||||||
}
|
}
|
||||||
|
mainHandler.post(diagnosticsRefresh);
|
||||||
|
updateDiagnosticsVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onPause() {
|
protected void onPause() {
|
||||||
|
mainHandler.removeCallbacks(diagnosticsRefresh);
|
||||||
stopRobotAnimation();
|
stopRobotAnimation();
|
||||||
super.onPause();
|
super.onPause();
|
||||||
}
|
}
|
||||||
@@ -199,9 +231,6 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
|||||||
if (awaitingOverlay == null) {
|
if (awaitingOverlay == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (surfaceView != null) {
|
|
||||||
surfaceView.setVisibility(View.INVISIBLE);
|
|
||||||
}
|
|
||||||
awaitingOverlay.setVisibility(View.VISIBLE);
|
awaitingOverlay.setVisibility(View.VISIBLE);
|
||||||
awaitingOverlay.bringToFront();
|
awaitingOverlay.bringToFront();
|
||||||
if (awaitingMessage != null) {
|
if (awaitingMessage != null) {
|
||||||
@@ -214,23 +243,66 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
|||||||
if (awaitingOverlay != null) {
|
if (awaitingOverlay != null) {
|
||||||
awaitingOverlay.setVisibility(View.GONE);
|
awaitingOverlay.setVisibility(View.GONE);
|
||||||
}
|
}
|
||||||
if (surfaceView != null) {
|
|
||||||
surfaceView.setVisibility(View.VISIBLE);
|
|
||||||
}
|
|
||||||
stopRobotAnimation();
|
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() {
|
private void startRobotAnimation() {
|
||||||
if (robotView == null || robotDance == null) {
|
if (robotView == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
robotView.clearAnimation();
|
stopRobotAnimation();
|
||||||
robotView.startAnimation(robotDance);
|
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() {
|
private void stopRobotAnimation() {
|
||||||
|
if (robotAnimator != null) {
|
||||||
|
robotAnimator.cancel();
|
||||||
|
robotAnimator = null;
|
||||||
|
}
|
||||||
if (robotView != 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) {
|
if (videoWidth > 0 && videoHeight > 0) {
|
||||||
holder.setFixedSize(videoWidth, videoHeight);
|
holder.setFixedSize(videoWidth, videoHeight);
|
||||||
}
|
}
|
||||||
|
surfaceSentToService = false;
|
||||||
attachSurfaceIfReady();
|
attachSurfaceIfReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import com.foxx.androidcast.network.CastProtocol;
|
|||||||
import com.foxx.androidcast.network.CastSession;
|
import com.foxx.androidcast.network.CastSession;
|
||||||
import com.foxx.androidcast.network.CastTransportFactory;
|
import com.foxx.androidcast.network.CastTransportFactory;
|
||||||
import com.foxx.androidcast.network.control.NetworkFeedbackManager;
|
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.io.IOException;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
@@ -34,6 +36,8 @@ public class ReceiverSession {
|
|||||||
void onDisconnected();
|
void onDisconnected();
|
||||||
|
|
||||||
void onNetworkAdviceApplied();
|
void onNetworkAdviceApplied();
|
||||||
|
|
||||||
|
void onPeerNetworkStats(NetworkStatsSnapshot stats);
|
||||||
}
|
}
|
||||||
|
|
||||||
private final String pin;
|
private final String pin;
|
||||||
@@ -94,6 +98,16 @@ public class ReceiverSession {
|
|||||||
Log.i(TAG, "Connection closed");
|
Log.i(TAG, "Connection closed");
|
||||||
postStatus("Sender disconnected");
|
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) {
|
} catch (Exception e) {
|
||||||
if (running.get()) {
|
if (running.get()) {
|
||||||
Log.e(TAG, "Session error", e);
|
Log.e(TAG, "Session error", e);
|
||||||
@@ -130,6 +144,12 @@ public class ReceiverSession {
|
|||||||
}
|
}
|
||||||
if (networkFeedback != null && networkFeedback.handleMessage(msg)) {
|
if (networkFeedback != null && networkFeedback.handleMessage(msg)) {
|
||||||
listener.onNetworkAdviceApplied();
|
listener.onNetworkAdviceApplied();
|
||||||
|
if (msg.type == CastProtocol.MSG_NETWORK_STATS) {
|
||||||
|
try {
|
||||||
|
listener.onPeerNetworkStats(CastProtocol.parseNetworkStats(msg.payload));
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
@@ -139,6 +159,7 @@ public class ReceiverSession {
|
|||||||
break;
|
break;
|
||||||
case CastProtocol.MSG_VIDEO_FRAME:
|
case CastProtocol.MSG_VIDEO_FRAME:
|
||||||
CastProtocol.VideoFrame vf = CastProtocol.parseVideoFrame(msg.payload);
|
CastProtocol.VideoFrame vf = CastProtocol.parseVideoFrame(msg.payload);
|
||||||
|
recordNetworkBytes(vf.data != null ? vf.data.length : 0);
|
||||||
listener.onVideoFrame(vf.ptsUs, vf.keyFrame, vf.data);
|
listener.onVideoFrame(vf.ptsUs, vf.keyFrame, vf.data);
|
||||||
break;
|
break;
|
||||||
case CastProtocol.MSG_AUDIO_CONFIG:
|
case CastProtocol.MSG_AUDIO_CONFIG:
|
||||||
@@ -147,10 +168,11 @@ public class ReceiverSession {
|
|||||||
break;
|
break;
|
||||||
case CastProtocol.MSG_AUDIO_FRAME:
|
case CastProtocol.MSG_AUDIO_FRAME:
|
||||||
CastProtocol.AudioFrame af = CastProtocol.parseAudioFrame(msg.payload);
|
CastProtocol.AudioFrame af = CastProtocol.parseAudioFrame(msg.payload);
|
||||||
|
recordNetworkBytes(af.data != null ? af.data.length : 0);
|
||||||
listener.onAudioFrame(af.ptsUs, af.data);
|
listener.onAudioFrame(af.ptsUs, af.data);
|
||||||
break;
|
break;
|
||||||
case CastProtocol.MSG_GOODBYE:
|
case CastProtocol.MSG_GOODBYE:
|
||||||
postStatus("Sender disconnected");
|
postStatus("Sender stopped casting");
|
||||||
return;
|
return;
|
||||||
default:
|
default:
|
||||||
break;
|
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) {
|
private void postStatus(String status) {
|
||||||
listener.onStatus(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 {
|
public interface Listener {
|
||||||
void onFirstFrameRendered();
|
void onFirstFrameRendered();
|
||||||
|
|
||||||
|
/** Called for each frame after the first. */
|
||||||
|
default void onFrameRendered() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final Object lock = new Object();
|
private final Object lock = new Object();
|
||||||
@@ -154,13 +157,17 @@ public class VideoDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void notifyFirstFrameLocked() {
|
private void notifyFirstFrameLocked() {
|
||||||
if (firstFrameNotified || listener == null) {
|
if (listener == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
firstFrameNotified = true;
|
|
||||||
Listener cb = listener;
|
Listener cb = listener;
|
||||||
Log.i(TAG, "First frame rendered");
|
if (!firstFrameNotified) {
|
||||||
cb.onFirstFrameRendered();
|
firstFrameNotified = true;
|
||||||
|
Log.i(TAG, "First frame rendered");
|
||||||
|
cb.onFirstFrameRendered();
|
||||||
|
} else {
|
||||||
|
cb.onFrameRendered();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class PendingFrame {
|
private static final class PendingFrame {
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ public class DeviceListAdapter extends BaseAdapter {
|
|||||||
inflater = LayoutInflater.from(context);
|
inflater = LayoutInflater.from(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void clearDevices() {
|
||||||
|
devices.clear();
|
||||||
|
selectedPosition = -1;
|
||||||
|
notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
|
||||||
public void setDevices(List<DiscoveryManager.DiscoveredDevice> list) {
|
public void setDevices(List<DiscoveryManager.DiscoveredDevice> list) {
|
||||||
devices.clear();
|
devices.clear();
|
||||||
if (list != null) {
|
if (list != null) {
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ public class ScreenCastService extends Service {
|
|||||||
updateStatus(getString(R.string.connecting_to, host));
|
updateStatus(getString(R.string.connecting_to, host));
|
||||||
session = new CastSession(CastTransportFactory.create(settings));
|
session = new CastSession(CastTransportFactory.create(settings));
|
||||||
updateStatus(getString(R.string.waiting_receiver, host, port));
|
updateStatus(getString(R.string.waiting_receiver, host, port));
|
||||||
connectWithRetry(host, port);
|
connectWithRetry(host, port, settings);
|
||||||
updateStatus(getString(R.string.authenticating));
|
updateStatus(getString(R.string.authenticating));
|
||||||
session.clientHandshake(deviceName != null ? deviceName : "Sender", pin, settings);
|
session.clientHandshake(deviceName != null ? deviceName : "Sender", pin, settings);
|
||||||
networkFeedback = new NetworkFeedbackManager(session, true);
|
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;
|
IOException last = null;
|
||||||
for (int i = 0; i < CONNECT_RETRIES; i++) {
|
for (int i = 0; i < CONNECT_RETRIES; i++) {
|
||||||
try {
|
try {
|
||||||
|
if (session != null) {
|
||||||
|
session.close();
|
||||||
|
}
|
||||||
|
session = new CastSession(CastTransportFactory.create(settings));
|
||||||
session.connect(host, port);
|
session.connect(host, port);
|
||||||
return;
|
return;
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
last = e;
|
last = e;
|
||||||
Log.w(TAG, "Connect attempt " + (i + 1) + " failed", e);
|
Log.w(TAG, "Connect attempt " + (i + 1) + " failed", e);
|
||||||
|
if (session != null) {
|
||||||
|
session.close();
|
||||||
|
session = null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
Thread.sleep(CONNECT_RETRY_MS);
|
Thread.sleep(CONNECT_RETRY_MS);
|
||||||
} catch (InterruptedException ie) {
|
} catch (InterruptedException ie) {
|
||||||
|
|||||||
@@ -89,8 +89,11 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void restartDiscovery() {
|
private void restartDiscovery() {
|
||||||
|
deviceAdapter.clearDevices();
|
||||||
|
statusText.setText(R.string.searching);
|
||||||
if (discovery != null) {
|
if (discovery != null) {
|
||||||
discovery.stop();
|
discovery.stop();
|
||||||
|
discovery.clearDevices();
|
||||||
}
|
}
|
||||||
discovery.startBrowsing();
|
discovery.startBrowsing();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,9 @@
|
|||||||
layout="@layout/overlay_stream_awaiting"
|
layout="@layout/overlay_stream_awaiting"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent" />
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
|
<include
|
||||||
|
layout="@layout/overlay_cast_diagnostics"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
</FrameLayout>
|
</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:id="@+id/overlay_awaiting"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="#CC000000"
|
android:background="#FF000000"
|
||||||
android:clickable="true"
|
android:clickable="true"
|
||||||
android:elevation="16dp"
|
android:elevation="16dp"
|
||||||
android:focusable="true">
|
android:focusable="true">
|
||||||
|
|||||||
Reference in New Issue
Block a user