mirror of
git://f0xx.org/android_cast
synced 2026-07-29 05:58:14 +03:00
snap
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -4,5 +4,6 @@ interface ICastStatusCallback {
|
||||
void onStatus(String status);
|
||||
void onAuthenticated(String senderName);
|
||||
void onStreamStarted(int width, int height);
|
||||
void onVideoRendering();
|
||||
void onDisconnected();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user