1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 05:58:14 +03:00
This commit is contained in:
Anton Afanasyeu
2026-05-15 17:46:56 +02:00
parent 097c83dc69
commit 3ddf71064d
16 changed files with 946 additions and 183 deletions

View File

@@ -48,11 +48,13 @@
<service
android:name=".sender.ScreenCastService"
android:exported="false"
android:stopWithTask="true"
android:foregroundServiceType="mediaProjection" />
<service
android:name=".receiver.ReceiverCastService"
android:exported="false"
android:stopWithTask="true"
android:foregroundServiceType="mediaPlayback" />
</application>
</manifest>

View File

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

View File

@@ -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;
}
}

View File

@@ -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();
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 {
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 {
synchronized (receiveLock) {
if (socket == null || socket.isClosed() || in == null) {
return null;
}
if (timeoutMs > 0) {
socket.setSoTimeout(timeoutMs);
}
try {
return CastProtocol.readMessage(in);
} catch (java.net.SocketTimeoutException e) {
} 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;
}
}

View File

@@ -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);
}
}

View File

@@ -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<ICastStatusCallback> 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;

View File

@@ -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

View File

@@ -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);

View File

@@ -12,22 +12,32 @@ 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<PendingFrame> 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;
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);
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)));
}
@@ -38,68 +48,119 @@ public class VideoDecoder {
codec.configure(format, surface, null, 0);
codec.start();
configured = true;
drainPending();
Log.i(TAG, "Configured " + width + "x" + height);
drainPendingLocked();
}
}
public synchronized void queueFrame(long ptsUs, boolean keyFrame, byte[] data) {
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) {
if (!configured || codec == null) {
pending.add(new PendingFrame(ptsUs, keyFrame, annexB));
return;
}
decodeFrame(ptsUs, keyFrame, annexB);
decodeFrameLocked(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() {
private void drainOutputLocked(MediaCodec c) {
try {
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
int outIndex = codec.dequeueOutputBuffer(info, 0);
while (outIndex >= 0) {
codec.releaseOutputBuffer(outIndex, true);
outIndex = codec.dequeueOutputBuffer(info, 0);
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 {

View File

@@ -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) {
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,13 +421,18 @@ 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);
if (!stopping.get()) {
Log.w(TAG, "Send failed: " + e.getMessage());
stopping.set(true);
}
}
}
private interface PayloadBuilder {
byte[] build() throws IOException;
@@ -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;

View File

@@ -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<DiscoveryManager.DiscoveredDevice> devices = new ArrayList<>();
private ArrayAdapter<String> 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);
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("Selected: " + selected.name + " (" + selected.host + ", "
+ selected.transport.toUpperCase() + ")");
}
statusText.setText(getString(R.string.device_selected, selected.name, selected.host,
selected.transport.toUpperCase()));
});
discovery = new DiscoveryManager(this);
discovery.setListener(deviceList -> {
devices = deviceList;
List<String> 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);
}
}

View File

@@ -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" />
</com.foxx.androidcast.ui.AspectRatioFrameLayout>
<include
layout="@layout/overlay_stream_awaiting"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>

View File

@@ -25,6 +25,13 @@
android:hint="@string/pin_hint"
android:inputType="numberPassword" />
<CheckBox
android:id="@+id/check_allow_audio"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_receiver_audio" />
<Button
android:id="@+id/btn_start_receiver"
android:layout_width="match_parent"

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0D47A1</color>
<color name="device_item_selected">#4D1565C0</color>
</resources>

View File

@@ -39,7 +39,11 @@
<string name="casting_from">Casting from %1$s</string>
<string name="playing_resolution">Playing %1$dx%2$d</string>
<string name="stop">Stop</string>
<string name="label_receiver_audio">Play incoming cast audio on this device</string>
<string name="start_receiver">Start listening (keeps running in background)</string>
<string name="playback_buffering">Connected — waiting for video…</string>
<string name="playback_awaiting_stream">Awaiting for stream to come…</string>
<string name="playback_stream_lost">Stream lost — awaiting for stream to come…</string>
<string name="receiver_starting">Starting receiver service…</string>
<string name="receiver_listening">Listening for cast</string>
<string name="waiting_surface">Connected — open video surface…</string>
@@ -53,4 +57,14 @@
<string name="error_no_intent">No cast intent</string>
<string name="sender_check_notification">Cast starting — see notification for status</string>
<string name="playback_opened">Fullscreen playback %1$dx%2$d</string>
<string name="playback_rendering">Video playing</string>
<string name="device_selected">Selected: %1$s (%2$s, %3$s)</string>
<string name="audio_consent_title">Play cast audio?</string>
<string name="audio_consent_message">The sender is streaming audio. Play it on this device?</string>
<string name="audio_consent_yes">Play audio</string>
<string name="audio_consent_no">Video only</string>
<string name="audio_playing">Playing video and audio</string>
<string name="audio_skipped">Video only (audio skipped)</string>
<string name="casting_rotated">Rotated — now %1$dx%2$d</string>
<string name="waiting_receiver">Waiting for receiver at %1$s:%2$d…</string>
</resources>

View File

@@ -12,4 +12,9 @@
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowBackground">@android:color/black</item>
</style>
<style name="Theme.AndroidCast.Dialog" parent="Theme.MaterialComponents.DayNight.Dialog.Alert">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
</style>
</resources>