diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 99ec1b3..ce550b6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -48,11 +48,13 @@
diff --git a/app/src/main/aidl/com/foxx/androidcast/ICastStatusCallback.aidl b/app/src/main/aidl/com/foxx/androidcast/ICastStatusCallback.aidl
index 4528241..724f62f 100644
--- a/app/src/main/aidl/com/foxx/androidcast/ICastStatusCallback.aidl
+++ b/app/src/main/aidl/com/foxx/androidcast/ICastStatusCallback.aidl
@@ -4,5 +4,6 @@ interface ICastStatusCallback {
void onStatus(String status);
void onAuthenticated(String senderName);
void onStreamStarted(int width, int height);
+ void onVideoRendering();
void onDisconnected();
}
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 36a9663..f07e2c5 100644
--- a/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java
+++ b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java
@@ -2,6 +2,8 @@ package com.foxx.androidcast.network;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastSettings;
+import com.foxx.androidcast.network.control.NetworkControlAdvice;
+import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import java.io.DataInputStream;
import java.io.DataOutputStream;
@@ -22,6 +24,9 @@ public final class CastProtocol {
public static final byte MSG_AUDIO_CONFIG = 8;
public static final byte MSG_AUDIO_FRAME = 9;
public static final byte MSG_CAST_SETTINGS = 10;
+ public static final byte MSG_NETWORK_STATS = 11;
+ public static final byte MSG_NETWORK_CONTROL = 12;
+ public static final byte MSG_AUDIO_CONSENT = 13;
private CastProtocol() {}
@@ -165,6 +170,64 @@ public final class CastProtocol {
return local.getTransport().equals(remote.getTransport());
}
+ public static byte[] networkStatsPayload(NetworkStatsSnapshot s) throws IOException {
+ java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
+ DataOutputStream dos = new DataOutputStream(bos);
+ dos.writeLong(s.sequence);
+ dos.writeLong(s.timestampMs);
+ dos.writeInt(s.rttMs);
+ dos.writeFloat(s.lossRatio);
+ dos.writeInt(s.sendBitrateKbps);
+ dos.writeInt(s.recvBitrateKbps);
+ dos.writeInt(s.congestionLevel);
+ dos.writeInt(s.jitterMs);
+ dos.flush();
+ return bos.toByteArray();
+ }
+
+ public static NetworkStatsSnapshot parseNetworkStats(byte[] payload) throws IOException {
+ DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
+ NetworkStatsSnapshot s = new NetworkStatsSnapshot();
+ s.sequence = dis.readLong();
+ s.timestampMs = dis.readLong();
+ s.rttMs = dis.readInt();
+ s.lossRatio = dis.readFloat();
+ s.sendBitrateKbps = dis.readInt();
+ s.recvBitrateKbps = dis.readInt();
+ s.congestionLevel = dis.readInt();
+ s.jitterMs = dis.readInt();
+ return s;
+ }
+
+ public static byte[] networkControlPayload(NetworkControlAdvice advice) throws IOException {
+ java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
+ DataOutputStream dos = new DataOutputStream(bos);
+ dos.writeInt(advice.suggestedBitrateKbps);
+ dos.writeUTF(advice.suggestedTransport != null ? advice.suggestedTransport : CastConfig.TRANSPORT_TCP);
+ dos.writeByte(advice.switchTransport ? 1 : 0);
+ dos.writeInt(advice.congestionLevel);
+ dos.flush();
+ return bos.toByteArray();
+ }
+
+ public static NetworkControl parseNetworkControl(byte[] payload) throws IOException {
+ DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
+ NetworkControl c = new NetworkControl();
+ c.suggestedBitrateKbps = dis.readInt();
+ c.suggestedTransport = dis.readUTF();
+ c.switchTransport = dis.readByte() != 0;
+ c.congestionLevel = dis.readInt();
+ return c;
+ }
+
+ public static byte[] audioConsentPayload(boolean accept) {
+ return new byte[]{(byte) (accept ? 1 : 0)};
+ }
+
+ public static boolean parseAudioConsent(byte[] payload) {
+ return payload != null && payload.length > 0 && payload[0] != 0;
+ }
+
private static void writeBytes(DataOutputStream dos, byte[] data) throws IOException {
if (data == null) {
dos.writeInt(0);
@@ -260,4 +323,11 @@ public final class CastProtocol {
this.data = data;
}
}
+
+ public static final class NetworkControl {
+ public int suggestedBitrateKbps;
+ public String suggestedTransport;
+ public boolean switchTransport;
+ public int congestionLevel;
+ }
}
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 358c06e..85b5c83 100644
--- a/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java
+++ b/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java
@@ -3,12 +3,19 @@ package com.foxx.androidcast.network;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
+import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
+import java.net.SocketTimeoutException;
-/** Reliable message transport over TCP. */
+/** Reliable message transport over TCP (serialized send/receive). */
public class TcpCastTransport implements CastTransport {
+ private static final int CONNECT_TIMEOUT_MS = 10_000;
+
+ private final Object sendLock = new Object();
+ private final Object receiveLock = new Object();
+
private ServerSocket serverSocket;
private Socket socket;
private DataInputStream in;
@@ -18,18 +25,23 @@ public class TcpCastTransport implements CastTransport {
public void listen(int port) throws IOException {
serverSocket = new ServerSocket();
serverSocket.setReuseAddress(true);
- serverSocket.bind(new InetSocketAddress(port));
+ serverSocket.bind(new InetSocketAddress(InetAddress.getByName("0.0.0.0"), port));
socket = serverSocket.accept();
socket.setTcpNoDelay(true);
- openStreams();
- serverSocket.close();
+ try {
+ serverSocket.close();
+ } catch (IOException ignored) {
+ }
serverSocket = null;
+ openStreams();
}
@Override
public void connect(String host, int port) throws IOException {
- socket = new Socket(host, port);
+ InetAddress address = InetAddress.getByName(host);
+ socket = new Socket();
socket.setTcpNoDelay(true);
+ socket.connect(new InetSocketAddress(address, port), CONNECT_TIMEOUT_MS);
openStreams();
}
@@ -40,20 +52,35 @@ public class TcpCastTransport implements CastTransport {
@Override
public void send(byte type, byte[] payload) throws IOException {
- CastProtocol.writeMessage(out, type, payload);
+ synchronized (sendLock) {
+ if (socket == null || socket.isClosed() || out == null) {
+ throw new IOException("Socket closed");
+ }
+ CastProtocol.writeMessage(out, type, payload);
+ }
}
@Override
public CastProtocol.Message receive(int timeoutMs) throws IOException {
- if (timeoutMs > 0) {
- socket.setSoTimeout(timeoutMs);
- }
- try {
- return CastProtocol.readMessage(in);
- } catch (java.net.SocketTimeoutException e) {
- return null;
- } catch (java.io.EOFException e) {
- return null;
+ synchronized (receiveLock) {
+ if (socket == null || socket.isClosed() || in == null) {
+ return null;
+ }
+ if (timeoutMs > 0) {
+ socket.setSoTimeout(timeoutMs);
+ }
+ try {
+ return CastProtocol.readMessage(in);
+ } catch (SocketTimeoutException e) {
+ return null;
+ } catch (java.io.EOFException e) {
+ return null;
+ } catch (java.net.SocketException e) {
+ if ("Connection reset".equals(e.getMessage()) || "Socket closed".equals(e.getMessage())) {
+ return null;
+ }
+ throw e;
+ }
}
}
@@ -64,6 +91,14 @@ public class TcpCastTransport implements CastTransport {
@Override
public void close() {
+ synchronized (sendLock) {
+ synchronized (receiveLock) {
+ closeSockets();
+ }
+ }
+ }
+
+ private void closeSockets() {
try {
if (socket != null) {
socket.close();
@@ -71,12 +106,14 @@ public class TcpCastTransport implements CastTransport {
} catch (Exception ignored) {
}
try {
- if (serverSocket != null) {
+ if (serverSocket != null && !serverSocket.isClosed()) {
serverSocket.close();
}
} catch (Exception ignored) {
}
socket = null;
serverSocket = null;
+ in = null;
+ out = null;
}
}
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 23635fa..19135b6 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java
@@ -9,7 +9,9 @@ import android.os.Bundle;
import android.os.IBinder;
import android.os.RemoteException;
import android.text.TextUtils;
+import android.view.WindowManager;
import android.widget.Button;
+import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.TextView;
@@ -23,12 +25,13 @@ import com.foxx.androidcast.PermissionHelper;
import com.foxx.androidcast.R;
import com.foxx.androidcast.SettingsUi;
-/** Receiver setup only — fullscreen playback opens automatically when stream starts. */
+/** Receiver setup — listening runs in background; fullscreen playback opens when video stream starts. */
public class ReceiverActivity extends AppCompatActivity {
private final CastSettings castSettings = new CastSettings();
private TextView statusText;
private EditText pinInput;
+ private CheckBox allowAudioCheck;
private ICastReceiverService receiverService;
private boolean bound;
@@ -49,6 +52,11 @@ public class ReceiverActivity extends AppCompatActivity {
runOnUiThread(() -> statusText.setText(getString(R.string.playback_opened, width, height)));
}
+ @Override
+ public void onVideoRendering() {
+ runOnUiThread(() -> statusText.setText(R.string.playback_rendering));
+ }
+
@Override
public void onDisconnected() {
runOnUiThread(() -> statusText.setText(R.string.waiting_sender));
@@ -85,13 +93,17 @@ public class ReceiverActivity extends AppCompatActivity {
statusText = findViewById(R.id.text_status);
pinInput = findViewById(R.id.edit_pin);
pinInput.setText(CastConfig.DEFAULT_PIN);
+ allowAudioCheck = findViewById(R.id.check_allow_audio);
+ allowAudioCheck.setChecked(ReceiverPrefs.isAllowAudio(this));
+ allowAudioCheck.setOnCheckedChangeListener((btn, checked) ->
+ ReceiverPrefs.setAllowAudio(ReceiverActivity.this, checked));
+
findViewById(R.id.btn_start_receiver).setOnClickListener(v -> startReceiverService());
}
@Override
protected void onStart() {
super.onStart();
- startReceiverService();
bindService(new Intent(this, ReceiverCastService.class), connection, Context.BIND_AUTO_CREATE);
}
@@ -109,14 +121,38 @@ public class ReceiverActivity extends AppCompatActivity {
super.onStop();
}
+ @Override
+ protected void onResume() {
+ super.onResume();
+ updateKeepScreenOn();
+ }
+
+ @Override
+ protected void onPause() {
+ getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ super.onPause();
+ }
+
+ private void updateKeepScreenOn() {
+ if (bound && receiverService != null) {
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ } else {
+ getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ }
+ }
+
private void startReceiverService() {
String pin = pinInput.getText().toString().trim();
if (TextUtils.isEmpty(pin)) {
pin = CastConfig.DEFAULT_PIN;
}
+ boolean allowAudio = allowAudioCheck.isChecked();
+ ReceiverPrefs.setAllowAudio(this, allowAudio);
+
Intent intent = new Intent(this, ReceiverCastService.class);
intent.setAction(ReceiverCastService.ACTION_START);
intent.putExtra(ReceiverCastService.EXTRA_PIN, pin);
+ intent.putExtra(ReceiverCastService.EXTRA_ALLOW_AUDIO, allowAudio);
intent.putExtra(CastSettings.EXTRA, castSettings);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent);
@@ -124,5 +160,6 @@ public class ReceiverActivity extends AppCompatActivity {
startService(intent);
}
statusText.setText(R.string.receiver_starting);
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
}
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 83bd44e..8e9f63b 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
@@ -3,15 +3,16 @@ package com.foxx.androidcast.receiver;
import android.app.NotificationManager;
import android.app.Service;
import android.content.Intent;
+import android.os.Build;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
+import android.os.PowerManager;
import android.os.RemoteCallbackList;
import android.os.RemoteException;
import android.util.Log;
import android.view.Surface;
-import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastNotifications;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.ICastReceiverService;
@@ -20,12 +21,22 @@ import com.foxx.androidcast.IntentExtras;
import com.foxx.androidcast.R;
import com.foxx.androidcast.discovery.DiscoveryManager;
-/** Foreground receiver: discovery, TCP/UDP listen, decode A/V (survives rotation). */
+import java.util.Arrays;
+
+/**
+ * Foreground receiver: discovery, listen, decode.
+ * Playback UI opens only after {@link CastProtocol#MSG_STREAM_CONFIG} (sender is actually streaming).
+ */
public class ReceiverCastService extends Service {
private static final String TAG = "ReceiverCastService";
public static final String ACTION_START = "com.foxx.androidcast.RECEIVER_START";
public static final String ACTION_STOP = "com.foxx.androidcast.STOP_RECV";
public static final String EXTRA_PIN = "pin";
+ public static final String EXTRA_ALLOW_AUDIO = "allow_audio";
+
+ private enum Phase {
+ IDLE, LISTENING, CONNECTED, STREAMING
+ }
private final RemoteCallbackList callbacks = new RemoteCallbackList<>();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
@@ -37,13 +48,29 @@ public class ReceiverCastService extends Service {
private Surface attachedSurface;
private String status = "";
private CastSettings settings = new CastSettings();
- private boolean started;
+ private Phase phase = Phase.IDLE;
private int pendingWidth;
private int pendingHeight;
private byte[] pendingCsd0;
private byte[] pendingCsd1;
private boolean hasPendingConfig;
+ private volatile boolean reconfiguring;
+ private volatile boolean awaitingKeyframe = true;
+ private boolean playbackLaunched;
+ private int decoderWidth;
+ private int decoderHeight;
+ private boolean decoderActive;
+
+ /** Set from receiver UI before each listen session. */
+ private boolean allowIncomingAudio = true;
+ /** null until first audio config this session; then mirrors user preference. */
+ private Boolean audioAccepted;
+ private int pendingAudioRate;
+ private int pendingAudioChannels;
+ private byte[] pendingAudioCsd;
+ private PowerManager.WakeLock wakeLock;
+ private String activePin = com.foxx.androidcast.CastConfig.DEFAULT_PIN;
private final ICastReceiverService.Stub binder = new ICastReceiverService.Stub() {
@Override
@@ -97,30 +124,43 @@ public class ReceiverCastService extends Service {
if (intent != null && ACTION_START.equals(intent.getAction())) {
String pin = intent.getStringExtra(EXTRA_PIN);
if (pin == null || pin.isEmpty()) {
- pin = CastConfig.DEFAULT_PIN;
+ pin = com.foxx.androidcast.CastConfig.DEFAULT_PIN;
}
CastSettings fromIntent = IntentExtras.getCastSettings(intent);
if (fromIntent != null) {
settings = fromIntent;
}
- startReceiving(pin);
+ allowIncomingAudio = intent.getBooleanExtra(EXTRA_ALLOW_AUDIO,
+ ReceiverPrefs.isAllowAudio(this));
+ activePin = pin;
+ restartListening(pin);
+ } else if (intent == null && phase == Phase.IDLE) {
+ allowIncomingAudio = ReceiverPrefs.isAllowAudio(this);
+ restartListening(activePin);
}
return START_STICKY;
}
- private void startReceiving(String pin) {
- if (started) {
- return;
- }
- started = true;
+ private void restartListening(String pin) {
+ stopSessionOnly();
+ closePlaybackUi();
+ resetStreamState();
+ phase = Phase.LISTENING;
+ acquireWakeLock();
startForeground(CastNotifications.ID_RECEIVER,
CastNotifications.receiver(this, getString(R.string.receiver_listening), ReceiverCastService.class));
+ if (videoDecoder != null) {
+ videoDecoder.release();
+ }
+ if (audioDecoder != null) {
+ audioDecoder.release();
+ }
videoDecoder = new VideoDecoder();
audioDecoder = new AudioDecoder();
discovery = new DiscoveryManager(this);
- discovery.startAnnouncing(android.os.Build.MODEL, CastConfig.CAST_PORT, settings.getTransport());
+ discovery.startAnnouncing(android.os.Build.MODEL, com.foxx.androidcast.CastConfig.CAST_PORT, settings.getTransport());
session = new ReceiverSession(pin, settings, new ReceiverSession.Listener() {
@Override
@@ -130,8 +170,7 @@ public class ReceiverCastService extends Service {
@Override
public void onAuthenticated(String senderName, CastSettings remoteSettings) {
- updateStatus(getString(R.string.casting_from, senderName));
- broadcastAuthenticated(senderName);
+ mainHandler.post(() -> onSenderConnected(senderName));
}
@Override
@@ -141,49 +180,163 @@ public class ReceiverCastService extends Service {
@Override
public void onVideoFrame(long ptsUs, boolean keyFrame, byte[] data) {
- if (videoDecoder != null) {
- videoDecoder.queueFrame(ptsUs, keyFrame, data);
+ if (reconfiguring) {
+ return;
+ }
+ if (awaitingKeyframe && !keyFrame) {
+ return;
+ }
+ if (keyFrame) {
+ awaitingKeyframe = false;
}
- }
-
- @Override
- public void onAudioConfig(int sampleRate, int channels, byte[] csd0) {
mainHandler.post(() -> {
- try {
- if (audioDecoder != null) {
- audioDecoder.configure(sampleRate, channels, csd0);
- }
- } catch (Exception e) {
- updateStatus("Audio: " + e.getMessage());
+ if (videoDecoder != null && !reconfiguring) {
+ videoDecoder.queueFrame(ptsUs, keyFrame, data);
}
});
}
+ @Override
+ public void onAudioConfig(int sampleRate, int channels, byte[] csd0) {
+ // Network I/O must stay on the session reader thread (StrictMode).
+ final boolean accept;
+ synchronized (ReceiverCastService.this) {
+ if (audioAccepted == null) {
+ accept = allowIncomingAudio;
+ audioAccepted = accept;
+ if (session != null) {
+ session.sendAudioConsent(accept);
+ }
+ } else {
+ accept = Boolean.TRUE.equals(audioAccepted);
+ }
+ }
+ final boolean accepted = accept;
+ mainHandler.post(() -> onAudioConfigReceived(sampleRate, channels, csd0, accepted));
+ }
+
@Override
public void onAudioFrame(long ptsUs, byte[] data) {
- if (audioDecoder != null) {
+ if (Boolean.TRUE.equals(audioAccepted) && audioDecoder != null) {
audioDecoder.queueFrame(ptsUs, data);
}
}
@Override
public void onDisconnected() {
- broadcastDisconnected();
- updateStatus(getString(R.string.waiting_sender));
+ mainHandler.post(ReceiverCastService.this::onSenderDisconnected);
+ }
+
+ @Override
+ public void onNetworkAdviceApplied() {
}
});
session.start();
updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase()));
+ Log.i(TAG, "Listening PIN=" + pin + " audio=" + allowIncomingAudio);
+ }
+
+ private void onSenderConnected(String senderName) {
+ phase = Phase.CONNECTED;
+ audioAccepted = null;
+ playbackLaunched = false;
+ updateStatus(getString(R.string.casting_from, senderName));
+ broadcastAuthenticated(senderName);
+ Log.i(TAG, "Sender connected: " + senderName);
+ }
+
+ private void onSenderDisconnected() {
+ phase = Phase.LISTENING;
+ audioAccepted = null;
+ hasPendingConfig = false;
+ awaitingKeyframe = true;
+ detachSurfaceOnly();
+ broadcastDisconnected();
+ updateStatus(getString(R.string.waiting_sender));
+ Log.i(TAG, "Sender disconnected");
+ }
+
+ private void resetStreamState() {
+ hasPendingConfig = false;
+ playbackLaunched = false;
+ awaitingKeyframe = true;
+ pendingWidth = 0;
+ pendingHeight = 0;
+ pendingCsd0 = null;
+ pendingCsd1 = null;
+ resetDecoderState();
+ }
+
+ private void resetDecoderState() {
+ decoderActive = false;
+ decoderWidth = 0;
+ decoderHeight = 0;
+ }
+
+ private void onAudioConfigReceived(int sampleRate, int channels, byte[] csd0, boolean accepted) {
+ pendingAudioRate = sampleRate;
+ pendingAudioChannels = channels;
+ pendingAudioCsd = csd0;
+ if (accepted) {
+ configureAudioIfAccepted();
+ updateStatus(getString(R.string.audio_playing));
+ } else {
+ if (audioDecoder != null) {
+ audioDecoder.release();
+ audioDecoder = new AudioDecoder();
+ }
+ updateStatus(getString(R.string.audio_skipped));
+ }
+ Log.i(TAG, "Audio consent: " + accepted);
+ }
+
+ private void configureAudioIfAccepted() {
+ if (!Boolean.TRUE.equals(audioAccepted) || audioDecoder == null) {
+ return;
+ }
+ try {
+ audioDecoder.configure(pendingAudioRate, pendingAudioChannels, pendingAudioCsd);
+ } catch (Exception e) {
+ Log.e(TAG, "Audio configure failed", e);
+ updateStatus("Audio: " + e.getMessage());
+ }
}
private void onStreamConfigReceived(int width, int height, byte[] csd0, byte[] csd1) {
- pendingWidth = width;
- pendingHeight = height;
+ com.foxx.androidcast.media.StreamDimensionResolver.Size size =
+ com.foxx.androidcast.media.StreamDimensionResolver.resolve(width, height, csd0);
+ if (size.width <= 0 || size.height <= 0) {
+ Log.w(TAG, "Ignoring invalid stream config " + width + "x" + height);
+ return;
+ }
+ boolean same = hasPendingConfig
+ && pendingWidth == size.width
+ && pendingHeight == size.height
+ && Arrays.equals(pendingCsd0, csd0)
+ && Arrays.equals(pendingCsd1, csd1);
+ if (same && decoderActive) {
+ Log.d(TAG, "Stream config unchanged " + size.width + "x" + size.height);
+ return;
+ }
+ phase = Phase.STREAMING;
+ reconfiguring = true;
+ awaitingKeyframe = true;
+ pendingWidth = size.width;
+ pendingHeight = size.height;
pendingCsd0 = csd0;
pendingCsd1 = csd1;
hasPendingConfig = true;
- launchPlaybackActivity(width, height);
+ decoderActive = false;
+ mainHandler.postDelayed(() -> awaitingKeyframe = false, 2000);
+ if (!playbackLaunched) {
+ playbackLaunched = true;
+ launchPlaybackActivity(pendingWidth, pendingHeight);
+ } else {
+ notifyPlaybackDimensions(pendingWidth, pendingHeight);
+ }
tryConfigureDecoder();
+ reconfiguring = false;
+ Log.i(TAG, "Stream config " + pendingWidth + "x" + pendingHeight);
}
private void launchPlaybackActivity(int width, int height) {
@@ -194,36 +347,101 @@ public class ReceiverCastService extends Service {
startActivity(intent);
}
+ private void notifyPlaybackDimensions(int width, int height) {
+ broadcastStreamStarted(width, height);
+ }
+
+ private void closePlaybackUi() {
+ if (!playbackLaunched) {
+ return;
+ }
+ Intent intent = new Intent(this, ReceiverPlaybackActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
+ intent.putExtra(ReceiverPlaybackActivity.EXTRA_FINISH, true);
+ startActivity(intent);
+ playbackLaunched = false;
+ }
+
+ private void stopSessionOnly() {
+ if (discovery != null) {
+ discovery.stop();
+ discovery = null;
+ }
+ if (session != null) {
+ session.stop();
+ session = null;
+ }
+ }
+
+ private void acquireWakeLock() {
+ if (wakeLock != null && wakeLock.isHeld()) {
+ return;
+ }
+ PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
+ wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "AndroidCast:Receive");
+ wakeLock.acquire(4 * 60 * 60 * 1000L);
+ }
+
+ private void releaseWakeLock() {
+ if (wakeLock != null && wakeLock.isHeld()) {
+ wakeLock.release();
+ }
+ wakeLock = null;
+ }
+
private void tryConfigureDecoder() {
if (!hasPendingConfig || attachedSurface == null || !attachedSurface.isValid()) {
+ Log.i(TAG, "Decoder waiting: config=" + hasPendingConfig
+ + " surface=" + (attachedSurface != null && attachedSurface.isValid()));
+ return;
+ }
+ if (decoderActive && decoderWidth == pendingWidth && decoderHeight == pendingHeight) {
+ Log.d(TAG, "Decoder already active at " + decoderWidth + "x" + decoderHeight);
return;
}
try {
- if (videoDecoder == null) {
- videoDecoder = new VideoDecoder();
+ if (videoDecoder != null) {
+ videoDecoder.release();
}
- videoDecoder.configure(pendingWidth, pendingHeight, pendingCsd0, pendingCsd1, attachedSurface);
+ videoDecoder = new VideoDecoder();
+ videoDecoder.configure(pendingWidth, pendingHeight, pendingCsd0, pendingCsd1, attachedSurface,
+ this::onFirstFrameRendered);
+ decoderActive = true;
+ decoderWidth = pendingWidth;
+ decoderHeight = pendingHeight;
updateStatus(getString(R.string.playing_resolution, pendingWidth, pendingHeight));
broadcastStreamStarted(pendingWidth, pendingHeight);
- hasPendingConfig = false;
+ Log.i(TAG, "Decoder started " + pendingWidth + "x" + pendingHeight);
} catch (Exception e) {
+ decoderActive = false;
Log.e(TAG, "Video configure failed", e);
updateStatus("Video: " + e.getMessage());
}
}
+ private void onFirstFrameRendered() {
+ mainHandler.post(() -> {
+ Log.i(TAG, "First frame on screen");
+ broadcastVideoRendering();
+ });
+ }
+
private void attachSurface(Surface surface) {
+ if (surface != null && surface.equals(attachedSurface) && decoderActive) {
+ Log.d(TAG, "Surface unchanged, skip reconfigure");
+ return;
+ }
attachedSurface = surface;
tryConfigureDecoder();
}
private void detachSurfaceOnly() {
attachedSurface = null;
+ resetDecoderState();
if (videoDecoder != null) {
videoDecoder.release();
videoDecoder = new VideoDecoder();
}
- hasPendingConfig = pendingWidth > 0 && pendingHeight > 0;
}
private void updateStatus(String s) {
@@ -267,6 +485,17 @@ public class ReceiverCastService extends Service {
callbacks.finishBroadcast();
}
+ private void broadcastVideoRendering() {
+ int n = callbacks.beginBroadcast();
+ for (int i = 0; i < n; i++) {
+ try {
+ callbacks.getBroadcastItem(i).onVideoRendering();
+ } catch (RemoteException ignored) {
+ }
+ }
+ callbacks.finishBroadcast();
+ }
+
private void broadcastDisconnected() {
int n = callbacks.beginBroadcast();
for (int i = 0; i < n; i++) {
@@ -279,16 +508,12 @@ public class ReceiverCastService extends Service {
}
private void releaseAll() {
- started = false;
- hasPendingConfig = false;
- if (discovery != null) {
- discovery.stop();
- discovery = null;
- }
- if (session != null) {
- session.stop();
- session = null;
- }
+ phase = Phase.IDLE;
+ resetStreamState();
+ audioAccepted = null;
+ stopSessionOnly();
+ closePlaybackUi();
+ releaseWakeLock();
if (videoDecoder != null) {
videoDecoder.release();
videoDecoder = null;
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 a223ea4..8a1fba2 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java
@@ -4,7 +4,6 @@ import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
-import android.os.Build;
import android.os.Bundle;
import android.os.IBinder;
import android.os.RemoteException;
@@ -12,32 +11,86 @@ 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.widget.ImageView;
+import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
import com.foxx.androidcast.ICastReceiverService;
+import com.foxx.androidcast.ICastStatusCallback;
import com.foxx.androidcast.R;
import com.foxx.androidcast.ui.AspectRatioFrameLayout;
-/** Fullscreen video-only playback (no controls overlay). */
+/** Fullscreen video playback — robot overlay while waiting or after stream loss. */
public class ReceiverPlaybackActivity extends AppCompatActivity implements SurfaceHolder.Callback {
public static final String EXTRA_WIDTH = "video_width";
public static final String EXTRA_HEIGHT = "video_height";
+ public static final String EXTRA_FINISH = "finish_playback";
private AspectRatioFrameLayout aspectContainer;
private SurfaceView surfaceView;
+ private View awaitingOverlay;
+ private ImageView robotView;
+ private TextView awaitingMessage;
private SurfaceHolder surfaceHolder;
private int videoWidth;
private int videoHeight;
+ private boolean streamRendering;
+ private boolean surfaceSentToService;
+ private Animation robotDance;
private ICastReceiverService receiverService;
private boolean bound;
+ private final ICastStatusCallback.Stub statusCallback = new ICastStatusCallback.Stub() {
+ @Override
+ public void onStatus(String status) {
+ runOnUiThread(() -> {
+ if (status != null && status.toLowerCase().contains("waiting")) {
+ showAwaitingOverlay(R.string.playback_awaiting_stream);
+ }
+ });
+ }
+
+ @Override
+ public void onAuthenticated(String senderName) {
+ runOnUiThread(() -> showAwaitingOverlay(R.string.playback_awaiting_stream));
+ }
+
+ @Override
+ public void onStreamStarted(int width, int height) {
+ runOnUiThread(() -> updateVideoSize(width, height));
+ }
+
+ @Override
+ public void onVideoRendering() {
+ runOnUiThread(() -> {
+ streamRendering = true;
+ hideAwaitingOverlay();
+ });
+ }
+
+ @Override
+ public void onDisconnected() {
+ runOnUiThread(() -> onStreamLost());
+ }
+ };
+
private final ServiceConnection connection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
receiverService = ICastReceiverService.Stub.asInterface(service);
bound = true;
+ try {
+ receiverService.registerCallback(statusCallback);
+ String status = receiverService.getStatus();
+ if (status != null && status.toLowerCase().contains("waiting")) {
+ showAwaitingOverlay(R.string.playback_awaiting_stream);
+ }
+ } catch (RemoteException ignored) {
+ }
attachSurfaceIfReady();
}
@@ -51,27 +104,65 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
+ if (handleCommandIntent(getIntent())) {
+ return;
+ }
setContentView(R.layout.activity_playback);
applyFullscreen();
- videoWidth = getIntent().getIntExtra(EXTRA_WIDTH, 0);
- videoHeight = getIntent().getIntExtra(EXTRA_HEIGHT, 0);
+ readSizeFromIntent(getIntent());
aspectContainer = findViewById(R.id.aspect_container);
surfaceView = findViewById(R.id.surface_playback);
+ awaitingOverlay = findViewById(R.id.overlay_awaiting);
+ robotView = findViewById(R.id.image_robot);
+ awaitingMessage = findViewById(R.id.text_awaiting_message);
surfaceHolder = surfaceView.getHolder();
surfaceHolder.addCallback(this);
+ robotDance = AnimationUtils.loadAnimation(this, R.anim.robot_dance);
applyAspectRatio();
+ showAwaitingOverlay(R.string.playback_awaiting_stream);
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
+ if (handleCommandIntent(intent)) {
+ return;
+ }
+ readSizeFromIntent(intent);
+ updateVideoSize(videoWidth, videoHeight);
+ if (!streamRendering) {
+ showAwaitingOverlay(R.string.playback_awaiting_stream);
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (awaitingOverlay != null && awaitingOverlay.getVisibility() == View.VISIBLE) {
+ startRobotAnimation();
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ stopRobotAnimation();
+ super.onPause();
+ }
+
+ private boolean handleCommandIntent(Intent intent) {
+ if (intent != null && intent.getBooleanExtra(EXTRA_FINISH, false)) {
+ finish();
+ return true;
+ }
+ return false;
+ }
+
+ private void readSizeFromIntent(Intent intent) {
videoWidth = intent.getIntExtra(EXTRA_WIDTH, 0);
videoHeight = intent.getIntExtra(EXTRA_HEIGHT, 0);
- applyAspectRatio();
- attachSurfaceIfReady();
}
@Override
@@ -82,6 +173,12 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
@Override
protected void onStop() {
+ if (bound && receiverService != null) {
+ try {
+ receiverService.unregisterCallback(statusCallback);
+ } catch (RemoteException ignored) {
+ }
+ }
detachSurface();
if (bound) {
unbindService(connection);
@@ -91,6 +188,58 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
super.onStop();
}
+ private void onStreamLost() {
+ streamRendering = false;
+ showAwaitingOverlay(R.string.playback_stream_lost);
+ }
+
+ private void showAwaitingOverlay(int messageResId) {
+ streamRendering = false;
+ if (awaitingOverlay == null) {
+ return;
+ }
+ awaitingOverlay.setVisibility(View.VISIBLE);
+ if (awaitingMessage != null) {
+ awaitingMessage.setText(messageResId);
+ }
+ startRobotAnimation();
+ }
+
+ private void hideAwaitingOverlay() {
+ if (awaitingOverlay == null) {
+ return;
+ }
+ awaitingOverlay.setVisibility(View.GONE);
+ stopRobotAnimation();
+ }
+
+ private void startRobotAnimation() {
+ if (robotView == null || robotDance == null) {
+ return;
+ }
+ robotView.clearAnimation();
+ robotView.startAnimation(robotDance);
+ }
+
+ private void stopRobotAnimation() {
+ if (robotView != null) {
+ robotView.clearAnimation();
+ }
+ }
+
+ private void updateVideoSize(int width, int height) {
+ if (width <= 0 || height <= 0) {
+ return;
+ }
+ videoWidth = width;
+ videoHeight = height;
+ applyAspectRatio();
+ if (surfaceHolder != null && surfaceHolder.getSurface() != null && surfaceHolder.getSurface().isValid()) {
+ surfaceHolder.setFixedSize(videoWidth, videoHeight);
+ attachSurfaceIfReady();
+ }
+ }
+
private void applyFullscreen() {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
getWindow().getDecorView().setSystemUiVisibility(
@@ -121,16 +270,19 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
- attachSurfaceIfReady();
+ if (videoWidth > 0 && videoHeight > 0) {
+ holder.setFixedSize(videoWidth, videoHeight);
+ }
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
+ surfaceSentToService = false;
detachSurface();
}
private void attachSurfaceIfReady() {
- if (!bound || receiverService == null || surfaceHolder.getSurface() == null) {
+ if (surfaceSentToService || !bound || receiverService == null || surfaceHolder.getSurface() == null) {
return;
}
if (!surfaceHolder.getSurface().isValid()) {
@@ -138,18 +290,20 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
}
try {
receiverService.attachVideoSurface(surfaceHolder.getSurface());
+ surfaceSentToService = true;
} catch (RemoteException ignored) {
}
}
private void detachSurface() {
- if (receiverService == null) {
+ if (!surfaceSentToService || receiverService == null) {
return;
}
try {
receiverService.detachVideoSurface();
} catch (RemoteException ignored) {
}
+ surfaceSentToService = false;
}
@Override
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 27ab02e..1a1f266 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java
@@ -7,6 +7,7 @@ import com.foxx.androidcast.CastSettings;
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 java.io.IOException;
import java.util.concurrent.ExecutorService;
@@ -31,6 +32,8 @@ public class ReceiverSession {
void onAudioFrame(long ptsUs, byte[] data);
void onDisconnected();
+
+ void onNetworkAdviceApplied();
}
private final String pin;
@@ -40,6 +43,7 @@ public class ReceiverSession {
private final AtomicBoolean running = new AtomicBoolean(false);
private CastSession session;
+ private NetworkFeedbackManager networkFeedback;
public ReceiverSession(String pin, CastSettings localSettings, Listener listener) {
this.pin = pin;
@@ -62,10 +66,22 @@ public class ReceiverSession {
}
}
+ public void sendAudioConsent(boolean accept) {
+ if (session == null) {
+ return;
+ }
+ try {
+ session.send(CastProtocol.MSG_AUDIO_CONSENT, CastProtocol.audioConsentPayload(accept));
+ } catch (IOException e) {
+ Log.w(TAG, "Failed to send audio consent", e);
+ }
+ }
+
private void runServer() {
while (running.get()) {
try {
session = new CastSession(CastTransportFactory.create(localSettings));
+ networkFeedback = new NetworkFeedbackManager(session, false);
session.listen(CastConfig.CAST_PORT);
postStatus("Listening (" + session.getTransport().getModeLabel() + ") port "
+ CastConfig.CAST_PORT);
@@ -88,6 +104,7 @@ public class ReceiverSession {
session.close();
session = null;
}
+ networkFeedback = null;
listener.onDisconnected();
}
if (running.get()) {
@@ -98,9 +115,12 @@ public class ReceiverSession {
private void readStream() throws IOException {
while (running.get()) {
+ if (networkFeedback != null) {
+ networkFeedback.tick();
+ }
CastProtocol.Message msg;
try {
- msg = session.receive(5_000);
+ msg = session.receive(500);
} catch (java.io.EOFException e) {
Log.i(TAG, "Sender closed connection");
return;
@@ -108,6 +128,10 @@ public class ReceiverSession {
if (msg == null) {
continue;
}
+ if (networkFeedback != null && networkFeedback.handleMessage(msg)) {
+ listener.onNetworkAdviceApplied();
+ continue;
+ }
switch (msg.type) {
case CastProtocol.MSG_STREAM_CONFIG:
CastProtocol.StreamConfig vcfg = CastProtocol.parseStreamConfig(msg.payload);
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 276da45..0451380 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java
@@ -12,96 +12,157 @@ import java.nio.ByteBuffer;
import java.util.ArrayDeque;
import java.util.Queue;
-/** H.264 decoder feeding a Surface for playback. */
+/** Thread-safe H.264 decoder feeding a Surface. */
public class VideoDecoder {
private static final String TAG = "VideoDecoder";
private static final String MIME = MediaFormat.MIMETYPE_VIDEO_AVC;
+ public interface Listener {
+ void onFirstFrameRendered();
+ }
+
+ private final Object lock = new Object();
private MediaCodec codec;
private final Queue pending = new ArrayDeque<>();
private boolean configured;
- private int width;
- private int height;
+ private boolean released;
+ private Listener listener;
+ private boolean firstFrameNotified;
- public void configure(int width, int height, byte[] csd0, byte[] csd1, Surface surface) throws IOException {
- this.width = width;
- this.height = height;
- MediaFormat format = MediaFormat.createVideoFormat(MIME, width, height);
- format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, width * height);
- if (csd0 != null && csd0.length > 0) {
- format.setByteBuffer("csd-0", ByteBuffer.wrap(H264Bitstream.toAnnexB(csd0)));
+ public void configure(int width, int height, byte[] csd0, byte[] csd1, Surface surface, Listener listener)
+ throws IOException {
+ synchronized (lock) {
+ releaseLocked();
+ released = false;
+ this.listener = listener;
+ firstFrameNotified = false;
+ MediaFormat format = MediaFormat.createVideoFormat(MIME, width, height);
+ format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, width * height * 2);
+ if (csd0 != null && csd0.length > 0) {
+ format.setByteBuffer("csd-0", ByteBuffer.wrap(H264Bitstream.toAnnexB(csd0)));
+ }
+ if (csd1 != null && csd1.length > 0) {
+ format.setByteBuffer("csd-1", ByteBuffer.wrap(H264Bitstream.toAnnexB(csd1)));
+ }
+ codec = MediaCodec.createDecoderByType(MIME);
+ codec.configure(format, surface, null, 0);
+ codec.start();
+ configured = true;
+ Log.i(TAG, "Configured " + width + "x" + height);
+ drainPendingLocked();
}
- if (csd1 != null && csd1.length > 0) {
- format.setByteBuffer("csd-1", ByteBuffer.wrap(H264Bitstream.toAnnexB(csd1)));
- }
- codec = MediaCodec.createDecoderByType(MIME);
- codec.configure(format, surface, null, 0);
- codec.start();
- configured = true;
- drainPending();
}
- public synchronized void queueFrame(long ptsUs, boolean keyFrame, byte[] data) {
- byte[] annexB = H264Bitstream.toAnnexB(data);
- if (!configured) {
- pending.add(new PendingFrame(ptsUs, keyFrame, annexB));
- return;
+ public void queueFrame(long ptsUs, boolean keyFrame, byte[] data) {
+ synchronized (lock) {
+ if (released || data == null || data.length == 0) {
+ return;
+ }
+ byte[] annexB = H264Bitstream.toAnnexB(data);
+ if (!configured || codec == null) {
+ pending.add(new PendingFrame(ptsUs, keyFrame, annexB));
+ return;
+ }
+ decodeFrameLocked(ptsUs, keyFrame, annexB);
}
- decodeFrame(ptsUs, keyFrame, annexB);
}
public void release() {
+ synchronized (lock) {
+ releaseLocked();
+ }
+ }
+
+ private void releaseLocked() {
configured = false;
+ released = true;
+ listener = null;
+ firstFrameNotified = false;
pending.clear();
- if (codec != null) {
+ MediaCodec c = codec;
+ codec = null;
+ if (c != null) {
try {
- codec.stop();
+ c.stop();
+ } catch (Exception ignored) {
+ }
+ try {
+ c.release();
} catch (Exception ignored) {
}
- codec.release();
- codec = null;
}
}
- private void drainPending() {
+ private void drainPendingLocked() {
PendingFrame frame;
while ((frame = pending.poll()) != null) {
- decodeFrame(frame.ptsUs, frame.keyFrame, frame.data);
+ decodeFrameLocked(frame.ptsUs, frame.keyFrame, frame.data);
}
}
- private void decodeFrame(long ptsUs, boolean keyFrame, byte[] data) {
- if (codec == null || data == null || data.length == 0) {
+ private void decodeFrameLocked(long ptsUs, boolean keyFrame, byte[] data) {
+ MediaCodec c = codec;
+ if (c == null || !configured) {
return;
}
try {
- int inIndex = codec.dequeueInputBuffer(10_000);
+ int inIndex = c.dequeueInputBuffer(10_000);
if (inIndex < 0) {
return;
}
- ByteBuffer buffer = codec.getInputBuffer(inIndex);
+ ByteBuffer buffer = c.getInputBuffer(inIndex);
if (buffer == null) {
return;
}
buffer.clear();
buffer.put(data);
int flags = keyFrame ? MediaCodec.BUFFER_FLAG_KEY_FRAME : 0;
- codec.queueInputBuffer(inIndex, 0, data.length, ptsUs, flags);
- drainOutput();
+ c.queueInputBuffer(inIndex, 0, data.length, ptsUs, flags);
+ drainOutputLocked(c);
+ } catch (IllegalStateException e) {
+ Log.w(TAG, "Decode skipped (codec state): " + e.getMessage());
} catch (Exception e) {
Log.e(TAG, "Decode error", e);
}
}
- private void drainOutput() {
- MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
- int outIndex = codec.dequeueOutputBuffer(info, 0);
- while (outIndex >= 0) {
- codec.releaseOutputBuffer(outIndex, true);
- outIndex = codec.dequeueOutputBuffer(info, 0);
+ private void drainOutputLocked(MediaCodec c) {
+ try {
+ MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
+ for (;;) {
+ int outIndex = c.dequeueOutputBuffer(info, 10_000);
+ if (outIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
+ break;
+ }
+ if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
+ Log.i(TAG, "Output format: " + c.getOutputFormat());
+ continue;
+ }
+ if (outIndex < 0) {
+ continue;
+ }
+ boolean render = info.size > 0
+ && (info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0;
+ c.releaseOutputBuffer(outIndex, render);
+ if (render) {
+ notifyFirstFrameLocked();
+ }
+ }
+ } catch (IllegalStateException e) {
+ Log.w(TAG, "Drain skipped (codec state): " + e.getMessage());
}
}
+ private void notifyFirstFrameLocked() {
+ if (firstFrameNotified || listener == null) {
+ return;
+ }
+ firstFrameNotified = true;
+ Listener cb = listener;
+ Log.i(TAG, "First frame rendered");
+ cb.onFirstFrameRendered();
+ }
+
private static final class PendingFrame {
final long ptsUs;
final boolean keyFrame;
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 1d54f06..9f6c319 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
@@ -5,6 +5,7 @@ import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.hardware.display.DisplayManager;
import android.hardware.display.VirtualDisplay;
+import android.view.Display;
import android.media.projection.MediaProjection;
import android.media.projection.MediaProjectionManager;
import android.os.Build;
@@ -29,6 +30,7 @@ import com.foxx.androidcast.R;
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 java.io.IOException;
import java.util.concurrent.CountDownLatch;
@@ -39,8 +41,8 @@ import java.util.concurrent.atomic.AtomicReference;
/** Foreground sender: screen + audio capture → encode → TCP/UDP (survives UI exit). */
public class ScreenCastService extends Service {
private static final String TAG = "ScreenCastService";
- private static final int CONNECT_RETRIES = 5;
- private static final long CONNECT_RETRY_MS = 800;
+ private static final int CONNECT_RETRIES = 20;
+ private static final long CONNECT_RETRY_MS = 1000;
public static final String EXTRA_RESULT_CODE = "result_code";
public static final String EXTRA_RESULT_DATA = "result_data";
@@ -61,10 +63,18 @@ public class ScreenCastService extends Service {
private AudioEncoder audioEncoder;
private AudioCapture audioCapture;
private CastSession session;
+ private NetworkFeedbackManager networkFeedback;
private PowerManager.WakeLock wakeLock;
private Thread ioThread;
private String status = "";
+ private CastSettings activeSettings;
+ private int captureWidth;
+ private int captureHeight;
+ private int captureDensity;
+ private DisplayManager displayManager;
+ private DisplayManager.DisplayListener displayListener;
+
private final ICastSenderService.Stub binder = new ICastSenderService.Stub() {
@Override
public void registerCallback(ICastStatusCallback callback) {
@@ -144,17 +154,47 @@ public class ScreenCastService extends Service {
try {
updateStatus(getString(R.string.connecting_to, host));
session = new CastSession(CastTransportFactory.create(settings));
+ updateStatus(getString(R.string.waiting_receiver, host, port));
connectWithRetry(host, port);
updateStatus(getString(R.string.authenticating));
session.clientHandshake(deviceName != null ? deviceName : "Sender", pin, settings);
+ networkFeedback = new NetworkFeedbackManager(session, true);
casting.set(true);
+ activeSettings = settings;
startCaptureOnMainThread(resultCode, new Intent(data), settings);
while (!stopping.get()) {
- Thread.sleep(500);
+ try {
+ if (networkFeedback != null) {
+ networkFeedback.tick();
+ CastProtocol.Message msg = session.receive(100);
+ if (msg != null) {
+ if (networkFeedback.handleMessage(msg)) {
+ networkFeedback.applyAdvice(networkFeedback.getPlane().getOutboundAdvice());
+ } else if (msg.type == CastProtocol.MSG_AUDIO_CONSENT) {
+ handleAudioConsent(CastProtocol.parseAudioConsent(msg.payload));
+ }
+ }
+ }
+ Thread.sleep(400);
+ } catch (java.net.SocketException e) {
+ if (!stopping.get()) {
+ Log.i(TAG, "Connection lost: " + e.getMessage());
+ stopping.set(true);
+ }
+ break;
+ } catch (IOException e) {
+ if (!stopping.get()) {
+ Log.i(TAG, "Connection lost: " + e.getMessage());
+ stopping.set(true);
+ }
+ break;
+ }
}
} catch (Exception e) {
- Log.e(TAG, "Cast failed", e);
- failAndStop(e.getMessage() != null ? e.getMessage() : e.toString());
+ if (!stopping.get()) {
+ Log.e(TAG, "Cast failed", e);
+ failAndStop(e.getMessage() != null ? e.getMessage() : e.toString());
+ }
} finally {
releaseAll();
stopForeground(STOP_FOREGROUND_REMOVE);
@@ -228,38 +268,8 @@ public class ScreenCastService extends Service {
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
wm.getDefaultDisplay().getRealMetrics(metrics);
CastResolution.Size size = CastResolution.compute(metrics, settings);
-
- videoEncoder = new VideoEncoder();
- VideoEncoder.Callback videoCb = new VideoEncoder.Callback() {
- private boolean configSent;
-
- @Override
- public void onConfig(int w, int h, byte[] csd0, byte[] csd1) {
- if (configSent || stopping.get()) {
- return;
- }
- configSent = true;
- sendSafe(CastProtocol.MSG_STREAM_CONFIG,
- () -> CastProtocol.streamConfigPayload(w, h, csd0, csd1));
- }
-
- @Override
- public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
- sendSafe(CastProtocol.MSG_VIDEO_FRAME,
- () -> CastProtocol.videoFramePayload(ptsUs, keyFrame, frameData));
- }
- };
-
- Surface surface = videoEncoder.prepare(size.width, size.height, settings, videoCb);
- virtualDisplay = projection.createVirtualDisplay(
- "AndroidCast",
- size.width,
- size.height,
- size.densityDpi,
- DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
- surface,
- null,
- null);
+ setupVideoPipeline(size, settings);
+ registerDisplayListener();
if (settings.isAudioEnabled() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
audioEncoder = new AudioEncoder();
@@ -290,9 +300,117 @@ public class ScreenCastService extends Service {
});
}
- videoEncoder.requestKeyframe();
String mode = session.getTransport().getModeLabel();
- updateStatus(getString(R.string.casting_active, mode, size.width, size.height));
+ updateStatus(getString(R.string.casting_active, mode, captureWidth, captureHeight));
+ }
+
+ private void setupVideoPipeline(CastResolution.Size size, CastSettings settings) throws IOException {
+ if (virtualDisplay != null) {
+ virtualDisplay.release();
+ virtualDisplay = null;
+ }
+ if (videoEncoder != null) {
+ videoEncoder.stop();
+ videoEncoder = null;
+ }
+
+ captureWidth = size.width;
+ captureHeight = size.height;
+ captureDensity = size.densityDpi;
+
+ videoEncoder = new VideoEncoder();
+ VideoEncoder.Callback videoCb = new VideoEncoder.Callback() {
+ @Override
+ public void onConfig(int w, int h, byte[] csd0, byte[] csd1) {
+ if (stopping.get()) {
+ return;
+ }
+ sendSafe(CastProtocol.MSG_STREAM_CONFIG,
+ () -> CastProtocol.streamConfigPayload(w, h, csd0, csd1));
+ }
+
+ @Override
+ public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
+ sendSafe(CastProtocol.MSG_VIDEO_FRAME,
+ () -> CastProtocol.videoFramePayload(ptsUs, keyFrame, frameData));
+ }
+ };
+
+ Surface surface = videoEncoder.prepare(captureWidth, captureHeight, settings, videoCb);
+ virtualDisplay = projection.createVirtualDisplay(
+ "AndroidCast",
+ captureWidth,
+ captureHeight,
+ captureDensity,
+ DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
+ surface,
+ null,
+ null);
+ videoEncoder.requestKeyframe();
+ Log.i(TAG, "Video pipeline " + captureWidth + "x" + captureHeight);
+ }
+
+ private void registerDisplayListener() {
+ if (displayManager != null) {
+ return;
+ }
+ displayManager = (DisplayManager) getSystemService(DISPLAY_SERVICE);
+ displayListener = new DisplayManager.DisplayListener() {
+ @Override
+ public void onDisplayAdded(int displayId) {}
+
+ @Override
+ public void onDisplayRemoved(int displayId) {}
+
+ @Override
+ public void onDisplayChanged(int displayId) {
+ if (displayId == Display.DEFAULT_DISPLAY) {
+ mainHandler.post(ScreenCastService.this::maybeReconfigureForRotation);
+ }
+ }
+ };
+ displayManager.registerDisplayListener(displayListener, mainHandler);
+ }
+
+ private void unregisterDisplayListener() {
+ if (displayManager != null && displayListener != null) {
+ displayManager.unregisterDisplayListener(displayListener);
+ }
+ displayManager = null;
+ displayListener = null;
+ }
+
+ private void maybeReconfigureForRotation() {
+ if (!casting.get() || projection == null || activeSettings == null || stopping.get()) {
+ return;
+ }
+ DisplayMetrics metrics = new DisplayMetrics();
+ WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
+ wm.getDefaultDisplay().getRealMetrics(metrics);
+ CastResolution.Size size = CastResolution.compute(metrics, activeSettings);
+ if (size.width == captureWidth && size.height == captureHeight) {
+ return;
+ }
+ try {
+ setupVideoPipeline(size, activeSettings);
+ updateStatus(getString(R.string.casting_rotated, captureWidth, captureHeight));
+ } catch (IOException e) {
+ Log.e(TAG, "Rotation reconfigure failed", e);
+ }
+ }
+
+ private void handleAudioConsent(boolean accept) {
+ if (!accept) {
+ if (audioCapture != null) {
+ audioCapture.stop();
+ audioCapture = null;
+ }
+ if (audioEncoder != null) {
+ audioEncoder.stop();
+ audioEncoder = null;
+ }
+ Log.i(TAG, "Receiver declined audio");
+ }
}
private void send(byte type, byte[] payloadBytes) throws IOException {
@@ -303,11 +421,16 @@ public class ScreenCastService extends Service {
}
private void sendSafe(byte type, PayloadBuilder builder) {
+ if (stopping.get()) {
+ return;
+ }
try {
send(type, builder.build());
} catch (IOException e) {
- Log.e(TAG, "Send failed", e);
- stopping.set(true);
+ if (!stopping.get()) {
+ Log.w(TAG, "Send failed: " + e.getMessage());
+ stopping.set(true);
+ }
}
}
@@ -331,6 +454,8 @@ public class ScreenCastService extends Service {
private void releaseAll() {
stopping.set(true);
casting.set(false);
+ unregisterDisplayListener();
+ networkFeedback = null;
if (virtualDisplay != null) {
virtualDisplay.release();
virtualDisplay = null;
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 f4f5115..8719e6f 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java
@@ -7,7 +7,7 @@ import android.os.Build;
import android.os.Bundle;
import android.provider.Settings;
import android.text.TextUtils;
-import android.widget.ArrayAdapter;
+import android.view.WindowManager;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ListView;
@@ -24,17 +24,12 @@ import com.foxx.androidcast.R;
import com.foxx.androidcast.SettingsUi;
import com.foxx.androidcast.discovery.DiscoveryManager;
-import java.util.ArrayList;
-import java.util.List;
-
public class SenderActivity extends AppCompatActivity {
private static final int REQUEST_MEDIA_PROJECTION = 2001;
private final CastSettings castSettings = new CastSettings();
private DiscoveryManager discovery;
- private List devices = new ArrayList<>();
- private ArrayAdapter listAdapter;
- private DiscoveryManager.DiscoveredDevice selected;
+ private DeviceListAdapter deviceAdapter;
private EditText pinInput;
private TextView statusText;
@@ -50,29 +45,25 @@ public class SenderActivity extends AppCompatActivity {
pinInput.setText(CastConfig.DEFAULT_PIN);
statusText = findViewById(R.id.text_status);
ListView listView = findViewById(R.id.list_devices);
- listAdapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, new ArrayList<>());
- listView.setAdapter(listAdapter);
+ listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
+ deviceAdapter = new DeviceListAdapter(this);
+ listView.setAdapter(deviceAdapter);
listView.setOnItemClickListener((parent, view, position, id) -> {
- if (position < devices.size()) {
- selected = devices.get(position);
- if (!castSettings.getTransport().equals(selected.transport)) {
- Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
- }
- statusText.setText("Selected: " + selected.name + " (" + selected.host + ", "
- + selected.transport.toUpperCase() + ")");
+ deviceAdapter.setSelectedPosition(position);
+ DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice();
+ if (selected == null) {
+ return;
}
+ if (!castSettings.getTransport().equals(selected.transport)) {
+ Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
+ }
+ statusText.setText(getString(R.string.device_selected, selected.name, selected.host,
+ selected.transport.toUpperCase()));
});
discovery = new DiscoveryManager(this);
discovery.setListener(deviceList -> {
- devices = deviceList;
- List labels = new ArrayList<>();
- for (DiscoveryManager.DiscoveredDevice d : deviceList) {
- labels.add(d.name + " — " + d.host + " [" + d.transport.toUpperCase() + "]");
- }
- listAdapter.clear();
- listAdapter.addAll(labels);
- listAdapter.notifyDataSetChanged();
+ deviceAdapter.setDevices(deviceList);
statusText.setText(deviceList.isEmpty()
? getString(R.string.searching)
: getString(R.string.found_receivers, deviceList.size()));
@@ -105,6 +96,7 @@ public class SenderActivity extends AppCompatActivity {
}
private void startCastFlow() {
+ DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice();
if (selected == null) {
Toast.makeText(this, R.string.select_receiver, Toast.LENGTH_SHORT).show();
return;
@@ -131,6 +123,7 @@ public class SenderActivity extends AppCompatActivity {
if (requestCode != REQUEST_MEDIA_PROJECTION) {
return;
}
+ DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice();
if (resultCode != Activity.RESULT_OK || data == null || selected == null) {
Toast.makeText(this, R.string.capture_denied, Toast.LENGTH_SHORT).show();
return;
@@ -154,7 +147,8 @@ public class SenderActivity extends AppCompatActivity {
} else {
startService(serviceIntent);
}
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
Toast.makeText(this, R.string.sender_check_notification, Toast.LENGTH_LONG).show();
- finish();
+ moveTaskToBack(true);
}
}
diff --git a/app/src/main/res/layout/activity_playback.xml b/app/src/main/res/layout/activity_playback.xml
index 43600be..e39cc80 100644
--- a/app/src/main/res/layout/activity_playback.xml
+++ b/app/src/main/res/layout/activity_playback.xml
@@ -14,6 +14,12 @@
android:id="@+id/surface_playback"
android:layout_width="match_parent"
android:layout_height="match_parent"
- android:layout_gravity="center" />
+ android:layout_gravity="center"
+ android:keepScreenOn="true" />
+
+
diff --git a/app/src/main/res/layout/activity_receiver.xml b/app/src/main/res/layout/activity_receiver.xml
index 5213198..ad34a3d 100644
--- a/app/src/main/res/layout/activity_receiver.xml
+++ b/app/src/main/res/layout/activity_receiver.xml
@@ -25,6 +25,13 @@
android:hint="@string/pin_hint"
android:inputType="numberPassword" />
+
+