mirror of
git://f0xx.org/android_cast
synced 2026-07-29 06:39:09 +03:00
snap
This commit is contained in:
@@ -48,11 +48,13 @@
|
|||||||
<service
|
<service
|
||||||
android:name=".sender.ScreenCastService"
|
android:name=".sender.ScreenCastService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
android:stopWithTask="true"
|
||||||
android:foregroundServiceType="mediaProjection" />
|
android:foregroundServiceType="mediaProjection" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".receiver.ReceiverCastService"
|
android:name=".receiver.ReceiverCastService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
android:stopWithTask="true"
|
||||||
android:foregroundServiceType="mediaPlayback" />
|
android:foregroundServiceType="mediaPlayback" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ interface ICastStatusCallback {
|
|||||||
void onStatus(String status);
|
void onStatus(String status);
|
||||||
void onAuthenticated(String senderName);
|
void onAuthenticated(String senderName);
|
||||||
void onStreamStarted(int width, int height);
|
void onStreamStarted(int width, int height);
|
||||||
|
void onVideoRendering();
|
||||||
void onDisconnected();
|
void onDisconnected();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.foxx.androidcast.network;
|
|||||||
|
|
||||||
import com.foxx.androidcast.CastConfig;
|
import com.foxx.androidcast.CastConfig;
|
||||||
import com.foxx.androidcast.CastSettings;
|
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.DataInputStream;
|
||||||
import java.io.DataOutputStream;
|
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_CONFIG = 8;
|
||||||
public static final byte MSG_AUDIO_FRAME = 9;
|
public static final byte MSG_AUDIO_FRAME = 9;
|
||||||
public static final byte MSG_CAST_SETTINGS = 10;
|
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() {}
|
private CastProtocol() {}
|
||||||
|
|
||||||
@@ -165,6 +170,64 @@ public final class CastProtocol {
|
|||||||
return local.getTransport().equals(remote.getTransport());
|
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 {
|
private static void writeBytes(DataOutputStream dos, byte[] data) throws IOException {
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
dos.writeInt(0);
|
dos.writeInt(0);
|
||||||
@@ -260,4 +323,11 @@ public final class CastProtocol {
|
|||||||
this.data = data;
|
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.DataInputStream;
|
||||||
import java.io.DataOutputStream;
|
import java.io.DataOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.net.InetAddress;
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
import java.net.ServerSocket;
|
import java.net.ServerSocket;
|
||||||
import java.net.Socket;
|
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 {
|
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 ServerSocket serverSocket;
|
||||||
private Socket socket;
|
private Socket socket;
|
||||||
private DataInputStream in;
|
private DataInputStream in;
|
||||||
@@ -18,18 +25,23 @@ public class TcpCastTransport implements CastTransport {
|
|||||||
public void listen(int port) throws IOException {
|
public void listen(int port) throws IOException {
|
||||||
serverSocket = new ServerSocket();
|
serverSocket = new ServerSocket();
|
||||||
serverSocket.setReuseAddress(true);
|
serverSocket.setReuseAddress(true);
|
||||||
serverSocket.bind(new InetSocketAddress(port));
|
serverSocket.bind(new InetSocketAddress(InetAddress.getByName("0.0.0.0"), port));
|
||||||
socket = serverSocket.accept();
|
socket = serverSocket.accept();
|
||||||
socket.setTcpNoDelay(true);
|
socket.setTcpNoDelay(true);
|
||||||
openStreams();
|
try {
|
||||||
serverSocket.close();
|
serverSocket.close();
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
}
|
||||||
serverSocket = null;
|
serverSocket = null;
|
||||||
|
openStreams();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void connect(String host, int port) throws IOException {
|
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.setTcpNoDelay(true);
|
||||||
|
socket.connect(new InetSocketAddress(address, port), CONNECT_TIMEOUT_MS);
|
||||||
openStreams();
|
openStreams();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,20 +52,35 @@ public class TcpCastTransport implements CastTransport {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void send(byte type, byte[] payload) throws IOException {
|
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);
|
CastProtocol.writeMessage(out, type, payload);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CastProtocol.Message receive(int timeoutMs) throws IOException {
|
public CastProtocol.Message receive(int timeoutMs) throws IOException {
|
||||||
|
synchronized (receiveLock) {
|
||||||
|
if (socket == null || socket.isClosed() || in == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (timeoutMs > 0) {
|
if (timeoutMs > 0) {
|
||||||
socket.setSoTimeout(timeoutMs);
|
socket.setSoTimeout(timeoutMs);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return CastProtocol.readMessage(in);
|
return CastProtocol.readMessage(in);
|
||||||
} catch (java.net.SocketTimeoutException e) {
|
} catch (SocketTimeoutException e) {
|
||||||
return null;
|
return null;
|
||||||
} catch (java.io.EOFException e) {
|
} catch (java.io.EOFException e) {
|
||||||
return null;
|
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
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
|
synchronized (sendLock) {
|
||||||
|
synchronized (receiveLock) {
|
||||||
|
closeSockets();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeSockets() {
|
||||||
try {
|
try {
|
||||||
if (socket != null) {
|
if (socket != null) {
|
||||||
socket.close();
|
socket.close();
|
||||||
@@ -71,12 +106,14 @@ public class TcpCastTransport implements CastTransport {
|
|||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (serverSocket != null) {
|
if (serverSocket != null && !serverSocket.isClosed()) {
|
||||||
serverSocket.close();
|
serverSocket.close();
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
socket = null;
|
socket = null;
|
||||||
serverSocket = null;
|
serverSocket = null;
|
||||||
|
in = null;
|
||||||
|
out = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import android.os.Bundle;
|
|||||||
import android.os.IBinder;
|
import android.os.IBinder;
|
||||||
import android.os.RemoteException;
|
import android.os.RemoteException;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
|
import android.view.WindowManager;
|
||||||
import android.widget.Button;
|
import android.widget.Button;
|
||||||
|
import android.widget.CheckBox;
|
||||||
import android.widget.EditText;
|
import android.widget.EditText;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
|
|
||||||
@@ -23,12 +25,13 @@ import com.foxx.androidcast.PermissionHelper;
|
|||||||
import com.foxx.androidcast.R;
|
import com.foxx.androidcast.R;
|
||||||
import com.foxx.androidcast.SettingsUi;
|
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 {
|
public class ReceiverActivity extends AppCompatActivity {
|
||||||
private final CastSettings castSettings = new CastSettings();
|
private final CastSettings castSettings = new CastSettings();
|
||||||
|
|
||||||
private TextView statusText;
|
private TextView statusText;
|
||||||
private EditText pinInput;
|
private EditText pinInput;
|
||||||
|
private CheckBox allowAudioCheck;
|
||||||
|
|
||||||
private ICastReceiverService receiverService;
|
private ICastReceiverService receiverService;
|
||||||
private boolean bound;
|
private boolean bound;
|
||||||
@@ -49,6 +52,11 @@ public class ReceiverActivity extends AppCompatActivity {
|
|||||||
runOnUiThread(() -> statusText.setText(getString(R.string.playback_opened, width, height)));
|
runOnUiThread(() -> statusText.setText(getString(R.string.playback_opened, width, height)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onVideoRendering() {
|
||||||
|
runOnUiThread(() -> statusText.setText(R.string.playback_rendering));
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDisconnected() {
|
public void onDisconnected() {
|
||||||
runOnUiThread(() -> statusText.setText(R.string.waiting_sender));
|
runOnUiThread(() -> statusText.setText(R.string.waiting_sender));
|
||||||
@@ -85,13 +93,17 @@ public class ReceiverActivity extends AppCompatActivity {
|
|||||||
statusText = findViewById(R.id.text_status);
|
statusText = findViewById(R.id.text_status);
|
||||||
pinInput = findViewById(R.id.edit_pin);
|
pinInput = findViewById(R.id.edit_pin);
|
||||||
pinInput.setText(CastConfig.DEFAULT_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());
|
findViewById(R.id.btn_start_receiver).setOnClickListener(v -> startReceiverService());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onStart() {
|
protected void onStart() {
|
||||||
super.onStart();
|
super.onStart();
|
||||||
startReceiverService();
|
|
||||||
bindService(new Intent(this, ReceiverCastService.class), connection, Context.BIND_AUTO_CREATE);
|
bindService(new Intent(this, ReceiverCastService.class), connection, Context.BIND_AUTO_CREATE);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,14 +121,38 @@ public class ReceiverActivity extends AppCompatActivity {
|
|||||||
super.onStop();
|
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() {
|
private void startReceiverService() {
|
||||||
String pin = pinInput.getText().toString().trim();
|
String pin = pinInput.getText().toString().trim();
|
||||||
if (TextUtils.isEmpty(pin)) {
|
if (TextUtils.isEmpty(pin)) {
|
||||||
pin = CastConfig.DEFAULT_PIN;
|
pin = CastConfig.DEFAULT_PIN;
|
||||||
}
|
}
|
||||||
|
boolean allowAudio = allowAudioCheck.isChecked();
|
||||||
|
ReceiverPrefs.setAllowAudio(this, allowAudio);
|
||||||
|
|
||||||
Intent intent = new Intent(this, ReceiverCastService.class);
|
Intent intent = new Intent(this, ReceiverCastService.class);
|
||||||
intent.setAction(ReceiverCastService.ACTION_START);
|
intent.setAction(ReceiverCastService.ACTION_START);
|
||||||
intent.putExtra(ReceiverCastService.EXTRA_PIN, pin);
|
intent.putExtra(ReceiverCastService.EXTRA_PIN, pin);
|
||||||
|
intent.putExtra(ReceiverCastService.EXTRA_ALLOW_AUDIO, allowAudio);
|
||||||
intent.putExtra(CastSettings.EXTRA, castSettings);
|
intent.putExtra(CastSettings.EXTRA, castSettings);
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
startForegroundService(intent);
|
startForegroundService(intent);
|
||||||
@@ -124,5 +160,6 @@ public class ReceiverActivity extends AppCompatActivity {
|
|||||||
startService(intent);
|
startService(intent);
|
||||||
}
|
}
|
||||||
statusText.setText(R.string.receiver_starting);
|
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.NotificationManager;
|
||||||
import android.app.Service;
|
import android.app.Service;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
import android.os.Build;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.IBinder;
|
import android.os.IBinder;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
|
import android.os.PowerManager;
|
||||||
import android.os.RemoteCallbackList;
|
import android.os.RemoteCallbackList;
|
||||||
import android.os.RemoteException;
|
import android.os.RemoteException;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.Surface;
|
import android.view.Surface;
|
||||||
|
|
||||||
import com.foxx.androidcast.CastConfig;
|
|
||||||
import com.foxx.androidcast.CastNotifications;
|
import com.foxx.androidcast.CastNotifications;
|
||||||
import com.foxx.androidcast.CastSettings;
|
import com.foxx.androidcast.CastSettings;
|
||||||
import com.foxx.androidcast.ICastReceiverService;
|
import com.foxx.androidcast.ICastReceiverService;
|
||||||
@@ -20,12 +21,22 @@ import com.foxx.androidcast.IntentExtras;
|
|||||||
import com.foxx.androidcast.R;
|
import com.foxx.androidcast.R;
|
||||||
import com.foxx.androidcast.discovery.DiscoveryManager;
|
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 {
|
public class ReceiverCastService extends Service {
|
||||||
private static final String TAG = "ReceiverCastService";
|
private static final String TAG = "ReceiverCastService";
|
||||||
public static final String ACTION_START = "com.foxx.androidcast.RECEIVER_START";
|
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 ACTION_STOP = "com.foxx.androidcast.STOP_RECV";
|
||||||
public static final String EXTRA_PIN = "pin";
|
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 RemoteCallbackList<ICastStatusCallback> callbacks = new RemoteCallbackList<>();
|
||||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||||
@@ -37,13 +48,29 @@ public class ReceiverCastService extends Service {
|
|||||||
private Surface attachedSurface;
|
private Surface attachedSurface;
|
||||||
private String status = "";
|
private String status = "";
|
||||||
private CastSettings settings = new CastSettings();
|
private CastSettings settings = new CastSettings();
|
||||||
private boolean started;
|
private Phase phase = Phase.IDLE;
|
||||||
|
|
||||||
private int pendingWidth;
|
private int pendingWidth;
|
||||||
private int pendingHeight;
|
private int pendingHeight;
|
||||||
private byte[] pendingCsd0;
|
private byte[] pendingCsd0;
|
||||||
private byte[] pendingCsd1;
|
private byte[] pendingCsd1;
|
||||||
private boolean hasPendingConfig;
|
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() {
|
private final ICastReceiverService.Stub binder = new ICastReceiverService.Stub() {
|
||||||
@Override
|
@Override
|
||||||
@@ -97,30 +124,43 @@ public class ReceiverCastService extends Service {
|
|||||||
if (intent != null && ACTION_START.equals(intent.getAction())) {
|
if (intent != null && ACTION_START.equals(intent.getAction())) {
|
||||||
String pin = intent.getStringExtra(EXTRA_PIN);
|
String pin = intent.getStringExtra(EXTRA_PIN);
|
||||||
if (pin == null || pin.isEmpty()) {
|
if (pin == null || pin.isEmpty()) {
|
||||||
pin = CastConfig.DEFAULT_PIN;
|
pin = com.foxx.androidcast.CastConfig.DEFAULT_PIN;
|
||||||
}
|
}
|
||||||
CastSettings fromIntent = IntentExtras.getCastSettings(intent);
|
CastSettings fromIntent = IntentExtras.getCastSettings(intent);
|
||||||
if (fromIntent != null) {
|
if (fromIntent != null) {
|
||||||
settings = fromIntent;
|
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;
|
return START_STICKY;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startReceiving(String pin) {
|
private void restartListening(String pin) {
|
||||||
if (started) {
|
stopSessionOnly();
|
||||||
return;
|
closePlaybackUi();
|
||||||
}
|
resetStreamState();
|
||||||
started = true;
|
phase = Phase.LISTENING;
|
||||||
|
acquireWakeLock();
|
||||||
startForeground(CastNotifications.ID_RECEIVER,
|
startForeground(CastNotifications.ID_RECEIVER,
|
||||||
CastNotifications.receiver(this, getString(R.string.receiver_listening), ReceiverCastService.class));
|
CastNotifications.receiver(this, getString(R.string.receiver_listening), ReceiverCastService.class));
|
||||||
|
|
||||||
|
if (videoDecoder != null) {
|
||||||
|
videoDecoder.release();
|
||||||
|
}
|
||||||
|
if (audioDecoder != null) {
|
||||||
|
audioDecoder.release();
|
||||||
|
}
|
||||||
videoDecoder = new VideoDecoder();
|
videoDecoder = new VideoDecoder();
|
||||||
audioDecoder = new AudioDecoder();
|
audioDecoder = new AudioDecoder();
|
||||||
|
|
||||||
discovery = new DiscoveryManager(this);
|
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() {
|
session = new ReceiverSession(pin, settings, new ReceiverSession.Listener() {
|
||||||
@Override
|
@Override
|
||||||
@@ -130,8 +170,7 @@ public class ReceiverCastService extends Service {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onAuthenticated(String senderName, CastSettings remoteSettings) {
|
public void onAuthenticated(String senderName, CastSettings remoteSettings) {
|
||||||
updateStatus(getString(R.string.casting_from, senderName));
|
mainHandler.post(() -> onSenderConnected(senderName));
|
||||||
broadcastAuthenticated(senderName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -141,49 +180,163 @@ public class ReceiverCastService extends Service {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onVideoFrame(long ptsUs, boolean keyFrame, byte[] data) {
|
public void onVideoFrame(long ptsUs, boolean keyFrame, byte[] data) {
|
||||||
if (videoDecoder != null) {
|
if (reconfiguring) {
|
||||||
videoDecoder.queueFrame(ptsUs, keyFrame, data);
|
return;
|
||||||
}
|
}
|
||||||
|
if (awaitingKeyframe && !keyFrame) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (keyFrame) {
|
||||||
|
awaitingKeyframe = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onAudioConfig(int sampleRate, int channels, byte[] csd0) {
|
|
||||||
mainHandler.post(() -> {
|
mainHandler.post(() -> {
|
||||||
try {
|
if (videoDecoder != null && !reconfiguring) {
|
||||||
if (audioDecoder != null) {
|
videoDecoder.queueFrame(ptsUs, keyFrame, data);
|
||||||
audioDecoder.configure(sampleRate, channels, csd0);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
updateStatus("Audio: " + e.getMessage());
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
@Override
|
||||||
public void onAudioFrame(long ptsUs, byte[] data) {
|
public void onAudioFrame(long ptsUs, byte[] data) {
|
||||||
if (audioDecoder != null) {
|
if (Boolean.TRUE.equals(audioAccepted) && audioDecoder != null) {
|
||||||
audioDecoder.queueFrame(ptsUs, data);
|
audioDecoder.queueFrame(ptsUs, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDisconnected() {
|
public void onDisconnected() {
|
||||||
broadcastDisconnected();
|
mainHandler.post(ReceiverCastService.this::onSenderDisconnected);
|
||||||
updateStatus(getString(R.string.waiting_sender));
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNetworkAdviceApplied() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
session.start();
|
session.start();
|
||||||
updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase()));
|
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) {
|
private void onStreamConfigReceived(int width, int height, byte[] csd0, byte[] csd1) {
|
||||||
pendingWidth = width;
|
com.foxx.androidcast.media.StreamDimensionResolver.Size size =
|
||||||
pendingHeight = height;
|
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;
|
pendingCsd0 = csd0;
|
||||||
pendingCsd1 = csd1;
|
pendingCsd1 = csd1;
|
||||||
hasPendingConfig = true;
|
hasPendingConfig = true;
|
||||||
launchPlaybackActivity(width, height);
|
decoderActive = false;
|
||||||
|
mainHandler.postDelayed(() -> awaitingKeyframe = false, 2000);
|
||||||
|
if (!playbackLaunched) {
|
||||||
|
playbackLaunched = true;
|
||||||
|
launchPlaybackActivity(pendingWidth, pendingHeight);
|
||||||
|
} else {
|
||||||
|
notifyPlaybackDimensions(pendingWidth, pendingHeight);
|
||||||
|
}
|
||||||
tryConfigureDecoder();
|
tryConfigureDecoder();
|
||||||
|
reconfiguring = false;
|
||||||
|
Log.i(TAG, "Stream config " + pendingWidth + "x" + pendingHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void launchPlaybackActivity(int width, int height) {
|
private void launchPlaybackActivity(int width, int height) {
|
||||||
@@ -194,36 +347,101 @@ public class ReceiverCastService extends Service {
|
|||||||
startActivity(intent);
|
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() {
|
private void tryConfigureDecoder() {
|
||||||
if (!hasPendingConfig || attachedSurface == null || !attachedSurface.isValid()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (videoDecoder == null) {
|
if (videoDecoder != null) {
|
||||||
videoDecoder = new VideoDecoder();
|
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));
|
updateStatus(getString(R.string.playing_resolution, pendingWidth, pendingHeight));
|
||||||
broadcastStreamStarted(pendingWidth, pendingHeight);
|
broadcastStreamStarted(pendingWidth, pendingHeight);
|
||||||
hasPendingConfig = false;
|
Log.i(TAG, "Decoder started " + pendingWidth + "x" + pendingHeight);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
decoderActive = false;
|
||||||
Log.e(TAG, "Video configure failed", e);
|
Log.e(TAG, "Video configure failed", e);
|
||||||
updateStatus("Video: " + e.getMessage());
|
updateStatus("Video: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void onFirstFrameRendered() {
|
||||||
|
mainHandler.post(() -> {
|
||||||
|
Log.i(TAG, "First frame on screen");
|
||||||
|
broadcastVideoRendering();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void attachSurface(Surface surface) {
|
private void attachSurface(Surface surface) {
|
||||||
|
if (surface != null && surface.equals(attachedSurface) && decoderActive) {
|
||||||
|
Log.d(TAG, "Surface unchanged, skip reconfigure");
|
||||||
|
return;
|
||||||
|
}
|
||||||
attachedSurface = surface;
|
attachedSurface = surface;
|
||||||
tryConfigureDecoder();
|
tryConfigureDecoder();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void detachSurfaceOnly() {
|
private void detachSurfaceOnly() {
|
||||||
attachedSurface = null;
|
attachedSurface = null;
|
||||||
|
resetDecoderState();
|
||||||
if (videoDecoder != null) {
|
if (videoDecoder != null) {
|
||||||
videoDecoder.release();
|
videoDecoder.release();
|
||||||
videoDecoder = new VideoDecoder();
|
videoDecoder = new VideoDecoder();
|
||||||
}
|
}
|
||||||
hasPendingConfig = pendingWidth > 0 && pendingHeight > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateStatus(String s) {
|
private void updateStatus(String s) {
|
||||||
@@ -267,6 +485,17 @@ public class ReceiverCastService extends Service {
|
|||||||
callbacks.finishBroadcast();
|
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() {
|
private void broadcastDisconnected() {
|
||||||
int n = callbacks.beginBroadcast();
|
int n = callbacks.beginBroadcast();
|
||||||
for (int i = 0; i < n; i++) {
|
for (int i = 0; i < n; i++) {
|
||||||
@@ -279,16 +508,12 @@ public class ReceiverCastService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void releaseAll() {
|
private void releaseAll() {
|
||||||
started = false;
|
phase = Phase.IDLE;
|
||||||
hasPendingConfig = false;
|
resetStreamState();
|
||||||
if (discovery != null) {
|
audioAccepted = null;
|
||||||
discovery.stop();
|
stopSessionOnly();
|
||||||
discovery = null;
|
closePlaybackUi();
|
||||||
}
|
releaseWakeLock();
|
||||||
if (session != null) {
|
|
||||||
session.stop();
|
|
||||||
session = null;
|
|
||||||
}
|
|
||||||
if (videoDecoder != null) {
|
if (videoDecoder != null) {
|
||||||
videoDecoder.release();
|
videoDecoder.release();
|
||||||
videoDecoder = null;
|
videoDecoder = null;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import android.content.ComponentName;
|
|||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.ServiceConnection;
|
import android.content.ServiceConnection;
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.IBinder;
|
import android.os.IBinder;
|
||||||
import android.os.RemoteException;
|
import android.os.RemoteException;
|
||||||
@@ -12,32 +11,86 @@ import android.view.SurfaceHolder;
|
|||||||
import android.view.SurfaceView;
|
import android.view.SurfaceView;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.WindowManager;
|
import android.view.WindowManager;
|
||||||
|
import android.view.animation.Animation;
|
||||||
|
import android.view.animation.AnimationUtils;
|
||||||
|
import android.widget.ImageView;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
|
||||||
import com.foxx.androidcast.ICastReceiverService;
|
import com.foxx.androidcast.ICastReceiverService;
|
||||||
|
import com.foxx.androidcast.ICastStatusCallback;
|
||||||
import com.foxx.androidcast.R;
|
import com.foxx.androidcast.R;
|
||||||
import com.foxx.androidcast.ui.AspectRatioFrameLayout;
|
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 class ReceiverPlaybackActivity extends AppCompatActivity implements SurfaceHolder.Callback {
|
||||||
public static final String EXTRA_WIDTH = "video_width";
|
public static final String EXTRA_WIDTH = "video_width";
|
||||||
public static final String EXTRA_HEIGHT = "video_height";
|
public static final String EXTRA_HEIGHT = "video_height";
|
||||||
|
public static final String EXTRA_FINISH = "finish_playback";
|
||||||
|
|
||||||
private AspectRatioFrameLayout aspectContainer;
|
private AspectRatioFrameLayout aspectContainer;
|
||||||
private SurfaceView surfaceView;
|
private SurfaceView surfaceView;
|
||||||
|
private View awaitingOverlay;
|
||||||
|
private ImageView robotView;
|
||||||
|
private TextView awaitingMessage;
|
||||||
private SurfaceHolder surfaceHolder;
|
private SurfaceHolder surfaceHolder;
|
||||||
private int videoWidth;
|
private int videoWidth;
|
||||||
private int videoHeight;
|
private int videoHeight;
|
||||||
|
private boolean streamRendering;
|
||||||
|
private boolean surfaceSentToService;
|
||||||
|
private Animation robotDance;
|
||||||
|
|
||||||
private ICastReceiverService receiverService;
|
private ICastReceiverService receiverService;
|
||||||
private boolean bound;
|
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() {
|
private final ServiceConnection connection = new ServiceConnection() {
|
||||||
@Override
|
@Override
|
||||||
public void onServiceConnected(ComponentName name, IBinder service) {
|
public void onServiceConnected(ComponentName name, IBinder service) {
|
||||||
receiverService = ICastReceiverService.Stub.asInterface(service);
|
receiverService = ICastReceiverService.Stub.asInterface(service);
|
||||||
bound = true;
|
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();
|
attachSurfaceIfReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,27 +104,65 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
|||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
if (handleCommandIntent(getIntent())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setContentView(R.layout.activity_playback);
|
setContentView(R.layout.activity_playback);
|
||||||
applyFullscreen();
|
applyFullscreen();
|
||||||
|
|
||||||
videoWidth = getIntent().getIntExtra(EXTRA_WIDTH, 0);
|
readSizeFromIntent(getIntent());
|
||||||
videoHeight = getIntent().getIntExtra(EXTRA_HEIGHT, 0);
|
|
||||||
|
|
||||||
aspectContainer = findViewById(R.id.aspect_container);
|
aspectContainer = findViewById(R.id.aspect_container);
|
||||||
surfaceView = findViewById(R.id.surface_playback);
|
surfaceView = findViewById(R.id.surface_playback);
|
||||||
|
awaitingOverlay = findViewById(R.id.overlay_awaiting);
|
||||||
|
robotView = findViewById(R.id.image_robot);
|
||||||
|
awaitingMessage = findViewById(R.id.text_awaiting_message);
|
||||||
surfaceHolder = surfaceView.getHolder();
|
surfaceHolder = surfaceView.getHolder();
|
||||||
surfaceHolder.addCallback(this);
|
surfaceHolder.addCallback(this);
|
||||||
|
robotDance = AnimationUtils.loadAnimation(this, R.anim.robot_dance);
|
||||||
applyAspectRatio();
|
applyAspectRatio();
|
||||||
|
showAwaitingOverlay(R.string.playback_awaiting_stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onNewIntent(Intent intent) {
|
protected void onNewIntent(Intent intent) {
|
||||||
super.onNewIntent(intent);
|
super.onNewIntent(intent);
|
||||||
setIntent(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);
|
videoWidth = intent.getIntExtra(EXTRA_WIDTH, 0);
|
||||||
videoHeight = intent.getIntExtra(EXTRA_HEIGHT, 0);
|
videoHeight = intent.getIntExtra(EXTRA_HEIGHT, 0);
|
||||||
applyAspectRatio();
|
|
||||||
attachSurfaceIfReady();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -82,6 +173,12 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onStop() {
|
protected void onStop() {
|
||||||
|
if (bound && receiverService != null) {
|
||||||
|
try {
|
||||||
|
receiverService.unregisterCallback(statusCallback);
|
||||||
|
} catch (RemoteException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
detachSurface();
|
detachSurface();
|
||||||
if (bound) {
|
if (bound) {
|
||||||
unbindService(connection);
|
unbindService(connection);
|
||||||
@@ -91,6 +188,58 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
|||||||
super.onStop();
|
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() {
|
private void applyFullscreen() {
|
||||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||||
getWindow().getDecorView().setSystemUiVisibility(
|
getWindow().getDecorView().setSystemUiVisibility(
|
||||||
@@ -121,16 +270,19 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
|
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
|
||||||
attachSurfaceIfReady();
|
if (videoWidth > 0 && videoHeight > 0) {
|
||||||
|
holder.setFixedSize(videoWidth, videoHeight);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void surfaceDestroyed(SurfaceHolder holder) {
|
public void surfaceDestroyed(SurfaceHolder holder) {
|
||||||
|
surfaceSentToService = false;
|
||||||
detachSurface();
|
detachSurface();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void attachSurfaceIfReady() {
|
private void attachSurfaceIfReady() {
|
||||||
if (!bound || receiverService == null || surfaceHolder.getSurface() == null) {
|
if (surfaceSentToService || !bound || receiverService == null || surfaceHolder.getSurface() == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!surfaceHolder.getSurface().isValid()) {
|
if (!surfaceHolder.getSurface().isValid()) {
|
||||||
@@ -138,18 +290,20 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
receiverService.attachVideoSurface(surfaceHolder.getSurface());
|
receiverService.attachVideoSurface(surfaceHolder.getSurface());
|
||||||
|
surfaceSentToService = true;
|
||||||
} catch (RemoteException ignored) {
|
} catch (RemoteException ignored) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void detachSurface() {
|
private void detachSurface() {
|
||||||
if (receiverService == null) {
|
if (!surfaceSentToService || receiverService == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
receiverService.detachVideoSurface();
|
receiverService.detachVideoSurface();
|
||||||
} catch (RemoteException ignored) {
|
} catch (RemoteException ignored) {
|
||||||
}
|
}
|
||||||
|
surfaceSentToService = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.foxx.androidcast.CastSettings;
|
|||||||
import com.foxx.androidcast.network.CastProtocol;
|
import com.foxx.androidcast.network.CastProtocol;
|
||||||
import com.foxx.androidcast.network.CastSession;
|
import com.foxx.androidcast.network.CastSession;
|
||||||
import com.foxx.androidcast.network.CastTransportFactory;
|
import com.foxx.androidcast.network.CastTransportFactory;
|
||||||
|
import com.foxx.androidcast.network.control.NetworkFeedbackManager;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
@@ -31,6 +32,8 @@ public class ReceiverSession {
|
|||||||
void onAudioFrame(long ptsUs, byte[] data);
|
void onAudioFrame(long ptsUs, byte[] data);
|
||||||
|
|
||||||
void onDisconnected();
|
void onDisconnected();
|
||||||
|
|
||||||
|
void onNetworkAdviceApplied();
|
||||||
}
|
}
|
||||||
|
|
||||||
private final String pin;
|
private final String pin;
|
||||||
@@ -40,6 +43,7 @@ public class ReceiverSession {
|
|||||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||||
|
|
||||||
private CastSession session;
|
private CastSession session;
|
||||||
|
private NetworkFeedbackManager networkFeedback;
|
||||||
|
|
||||||
public ReceiverSession(String pin, CastSettings localSettings, Listener listener) {
|
public ReceiverSession(String pin, CastSettings localSettings, Listener listener) {
|
||||||
this.pin = pin;
|
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() {
|
private void runServer() {
|
||||||
while (running.get()) {
|
while (running.get()) {
|
||||||
try {
|
try {
|
||||||
session = new CastSession(CastTransportFactory.create(localSettings));
|
session = new CastSession(CastTransportFactory.create(localSettings));
|
||||||
|
networkFeedback = new NetworkFeedbackManager(session, false);
|
||||||
session.listen(CastConfig.CAST_PORT);
|
session.listen(CastConfig.CAST_PORT);
|
||||||
postStatus("Listening (" + session.getTransport().getModeLabel() + ") port "
|
postStatus("Listening (" + session.getTransport().getModeLabel() + ") port "
|
||||||
+ CastConfig.CAST_PORT);
|
+ CastConfig.CAST_PORT);
|
||||||
@@ -88,6 +104,7 @@ public class ReceiverSession {
|
|||||||
session.close();
|
session.close();
|
||||||
session = null;
|
session = null;
|
||||||
}
|
}
|
||||||
|
networkFeedback = null;
|
||||||
listener.onDisconnected();
|
listener.onDisconnected();
|
||||||
}
|
}
|
||||||
if (running.get()) {
|
if (running.get()) {
|
||||||
@@ -98,9 +115,12 @@ public class ReceiverSession {
|
|||||||
|
|
||||||
private void readStream() throws IOException {
|
private void readStream() throws IOException {
|
||||||
while (running.get()) {
|
while (running.get()) {
|
||||||
|
if (networkFeedback != null) {
|
||||||
|
networkFeedback.tick();
|
||||||
|
}
|
||||||
CastProtocol.Message msg;
|
CastProtocol.Message msg;
|
||||||
try {
|
try {
|
||||||
msg = session.receive(5_000);
|
msg = session.receive(500);
|
||||||
} catch (java.io.EOFException e) {
|
} catch (java.io.EOFException e) {
|
||||||
Log.i(TAG, "Sender closed connection");
|
Log.i(TAG, "Sender closed connection");
|
||||||
return;
|
return;
|
||||||
@@ -108,6 +128,10 @@ public class ReceiverSession {
|
|||||||
if (msg == null) {
|
if (msg == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (networkFeedback != null && networkFeedback.handleMessage(msg)) {
|
||||||
|
listener.onNetworkAdviceApplied();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case CastProtocol.MSG_STREAM_CONFIG:
|
case CastProtocol.MSG_STREAM_CONFIG:
|
||||||
CastProtocol.StreamConfig vcfg = CastProtocol.parseStreamConfig(msg.payload);
|
CastProtocol.StreamConfig vcfg = CastProtocol.parseStreamConfig(msg.payload);
|
||||||
|
|||||||
@@ -12,22 +12,32 @@ import java.nio.ByteBuffer;
|
|||||||
import java.util.ArrayDeque;
|
import java.util.ArrayDeque;
|
||||||
import java.util.Queue;
|
import java.util.Queue;
|
||||||
|
|
||||||
/** H.264 decoder feeding a Surface for playback. */
|
/** Thread-safe H.264 decoder feeding a Surface. */
|
||||||
public class VideoDecoder {
|
public class VideoDecoder {
|
||||||
private static final String TAG = "VideoDecoder";
|
private static final String TAG = "VideoDecoder";
|
||||||
private static final String MIME = MediaFormat.MIMETYPE_VIDEO_AVC;
|
private static final String MIME = MediaFormat.MIMETYPE_VIDEO_AVC;
|
||||||
|
|
||||||
|
public interface Listener {
|
||||||
|
void onFirstFrameRendered();
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Object lock = new Object();
|
||||||
private MediaCodec codec;
|
private MediaCodec codec;
|
||||||
private final Queue<PendingFrame> pending = new ArrayDeque<>();
|
private final Queue<PendingFrame> pending = new ArrayDeque<>();
|
||||||
private boolean configured;
|
private boolean configured;
|
||||||
private int width;
|
private boolean released;
|
||||||
private int height;
|
private Listener listener;
|
||||||
|
private boolean firstFrameNotified;
|
||||||
|
|
||||||
public void configure(int width, int height, byte[] csd0, byte[] csd1, Surface surface) throws IOException {
|
public void configure(int width, int height, byte[] csd0, byte[] csd1, Surface surface, Listener listener)
|
||||||
this.width = width;
|
throws IOException {
|
||||||
this.height = height;
|
synchronized (lock) {
|
||||||
|
releaseLocked();
|
||||||
|
released = false;
|
||||||
|
this.listener = listener;
|
||||||
|
firstFrameNotified = false;
|
||||||
MediaFormat format = MediaFormat.createVideoFormat(MIME, width, height);
|
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) {
|
if (csd0 != null && csd0.length > 0) {
|
||||||
format.setByteBuffer("csd-0", ByteBuffer.wrap(H264Bitstream.toAnnexB(csd0)));
|
format.setByteBuffer("csd-0", ByteBuffer.wrap(H264Bitstream.toAnnexB(csd0)));
|
||||||
}
|
}
|
||||||
@@ -38,68 +48,119 @@ public class VideoDecoder {
|
|||||||
codec.configure(format, surface, null, 0);
|
codec.configure(format, surface, null, 0);
|
||||||
codec.start();
|
codec.start();
|
||||||
configured = true;
|
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);
|
byte[] annexB = H264Bitstream.toAnnexB(data);
|
||||||
if (!configured) {
|
if (!configured || codec == null) {
|
||||||
pending.add(new PendingFrame(ptsUs, keyFrame, annexB));
|
pending.add(new PendingFrame(ptsUs, keyFrame, annexB));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
decodeFrame(ptsUs, keyFrame, annexB);
|
decodeFrameLocked(ptsUs, keyFrame, annexB);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void release() {
|
public void release() {
|
||||||
|
synchronized (lock) {
|
||||||
|
releaseLocked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void releaseLocked() {
|
||||||
configured = false;
|
configured = false;
|
||||||
|
released = true;
|
||||||
|
listener = null;
|
||||||
|
firstFrameNotified = false;
|
||||||
pending.clear();
|
pending.clear();
|
||||||
if (codec != null) {
|
MediaCodec c = codec;
|
||||||
|
codec = null;
|
||||||
|
if (c != null) {
|
||||||
try {
|
try {
|
||||||
codec.stop();
|
c.stop();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
c.release();
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
codec.release();
|
|
||||||
codec = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void drainPending() {
|
private void drainPendingLocked() {
|
||||||
PendingFrame frame;
|
PendingFrame frame;
|
||||||
while ((frame = pending.poll()) != null) {
|
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) {
|
private void decodeFrameLocked(long ptsUs, boolean keyFrame, byte[] data) {
|
||||||
if (codec == null || data == null || data.length == 0) {
|
MediaCodec c = codec;
|
||||||
|
if (c == null || !configured) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
int inIndex = codec.dequeueInputBuffer(10_000);
|
int inIndex = c.dequeueInputBuffer(10_000);
|
||||||
if (inIndex < 0) {
|
if (inIndex < 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ByteBuffer buffer = codec.getInputBuffer(inIndex);
|
ByteBuffer buffer = c.getInputBuffer(inIndex);
|
||||||
if (buffer == null) {
|
if (buffer == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
buffer.clear();
|
buffer.clear();
|
||||||
buffer.put(data);
|
buffer.put(data);
|
||||||
int flags = keyFrame ? MediaCodec.BUFFER_FLAG_KEY_FRAME : 0;
|
int flags = keyFrame ? MediaCodec.BUFFER_FLAG_KEY_FRAME : 0;
|
||||||
codec.queueInputBuffer(inIndex, 0, data.length, ptsUs, flags);
|
c.queueInputBuffer(inIndex, 0, data.length, ptsUs, flags);
|
||||||
drainOutput();
|
drainOutputLocked(c);
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
Log.w(TAG, "Decode skipped (codec state): " + e.getMessage());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Decode error", e);
|
Log.e(TAG, "Decode error", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void drainOutput() {
|
private void drainOutputLocked(MediaCodec c) {
|
||||||
|
try {
|
||||||
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
||||||
int outIndex = codec.dequeueOutputBuffer(info, 0);
|
for (;;) {
|
||||||
while (outIndex >= 0) {
|
int outIndex = c.dequeueOutputBuffer(info, 10_000);
|
||||||
codec.releaseOutputBuffer(outIndex, true);
|
if (outIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
|
||||||
outIndex = codec.dequeueOutputBuffer(info, 0);
|
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 {
|
private static final class PendingFrame {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.content.Intent;
|
|||||||
import android.content.pm.ServiceInfo;
|
import android.content.pm.ServiceInfo;
|
||||||
import android.hardware.display.DisplayManager;
|
import android.hardware.display.DisplayManager;
|
||||||
import android.hardware.display.VirtualDisplay;
|
import android.hardware.display.VirtualDisplay;
|
||||||
|
import android.view.Display;
|
||||||
import android.media.projection.MediaProjection;
|
import android.media.projection.MediaProjection;
|
||||||
import android.media.projection.MediaProjectionManager;
|
import android.media.projection.MediaProjectionManager;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
@@ -29,6 +30,7 @@ import com.foxx.androidcast.R;
|
|||||||
import com.foxx.androidcast.network.CastProtocol;
|
import com.foxx.androidcast.network.CastProtocol;
|
||||||
import com.foxx.androidcast.network.CastSession;
|
import com.foxx.androidcast.network.CastSession;
|
||||||
import com.foxx.androidcast.network.CastTransportFactory;
|
import com.foxx.androidcast.network.CastTransportFactory;
|
||||||
|
import com.foxx.androidcast.network.control.NetworkFeedbackManager;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.concurrent.CountDownLatch;
|
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). */
|
/** Foreground sender: screen + audio capture → encode → TCP/UDP (survives UI exit). */
|
||||||
public class ScreenCastService extends Service {
|
public class ScreenCastService extends Service {
|
||||||
private static final String TAG = "ScreenCastService";
|
private static final String TAG = "ScreenCastService";
|
||||||
private static final int CONNECT_RETRIES = 5;
|
private static final int CONNECT_RETRIES = 20;
|
||||||
private static final long CONNECT_RETRY_MS = 800;
|
private static final long CONNECT_RETRY_MS = 1000;
|
||||||
|
|
||||||
public static final String EXTRA_RESULT_CODE = "result_code";
|
public static final String EXTRA_RESULT_CODE = "result_code";
|
||||||
public static final String EXTRA_RESULT_DATA = "result_data";
|
public static final String EXTRA_RESULT_DATA = "result_data";
|
||||||
@@ -61,10 +63,18 @@ public class ScreenCastService extends Service {
|
|||||||
private AudioEncoder audioEncoder;
|
private AudioEncoder audioEncoder;
|
||||||
private AudioCapture audioCapture;
|
private AudioCapture audioCapture;
|
||||||
private CastSession session;
|
private CastSession session;
|
||||||
|
private NetworkFeedbackManager networkFeedback;
|
||||||
private PowerManager.WakeLock wakeLock;
|
private PowerManager.WakeLock wakeLock;
|
||||||
private Thread ioThread;
|
private Thread ioThread;
|
||||||
private String status = "";
|
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() {
|
private final ICastSenderService.Stub binder = new ICastSenderService.Stub() {
|
||||||
@Override
|
@Override
|
||||||
public void registerCallback(ICastStatusCallback callback) {
|
public void registerCallback(ICastStatusCallback callback) {
|
||||||
@@ -144,17 +154,47 @@ public class ScreenCastService extends Service {
|
|||||||
try {
|
try {
|
||||||
updateStatus(getString(R.string.connecting_to, host));
|
updateStatus(getString(R.string.connecting_to, host));
|
||||||
session = new CastSession(CastTransportFactory.create(settings));
|
session = new CastSession(CastTransportFactory.create(settings));
|
||||||
|
updateStatus(getString(R.string.waiting_receiver, host, port));
|
||||||
connectWithRetry(host, port);
|
connectWithRetry(host, port);
|
||||||
updateStatus(getString(R.string.authenticating));
|
updateStatus(getString(R.string.authenticating));
|
||||||
session.clientHandshake(deviceName != null ? deviceName : "Sender", pin, settings);
|
session.clientHandshake(deviceName != null ? deviceName : "Sender", pin, settings);
|
||||||
|
networkFeedback = new NetworkFeedbackManager(session, true);
|
||||||
casting.set(true);
|
casting.set(true);
|
||||||
|
activeSettings = settings;
|
||||||
startCaptureOnMainThread(resultCode, new Intent(data), settings);
|
startCaptureOnMainThread(resultCode, new Intent(data), settings);
|
||||||
while (!stopping.get()) {
|
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) {
|
} catch (Exception e) {
|
||||||
|
if (!stopping.get()) {
|
||||||
Log.e(TAG, "Cast failed", e);
|
Log.e(TAG, "Cast failed", e);
|
||||||
failAndStop(e.getMessage() != null ? e.getMessage() : e.toString());
|
failAndStop(e.getMessage() != null ? e.getMessage() : e.toString());
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
releaseAll();
|
releaseAll();
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||||
@@ -228,38 +268,8 @@ public class ScreenCastService extends Service {
|
|||||||
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
|
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
|
||||||
wm.getDefaultDisplay().getRealMetrics(metrics);
|
wm.getDefaultDisplay().getRealMetrics(metrics);
|
||||||
CastResolution.Size size = CastResolution.compute(metrics, settings);
|
CastResolution.Size size = CastResolution.compute(metrics, settings);
|
||||||
|
setupVideoPipeline(size, settings);
|
||||||
videoEncoder = new VideoEncoder();
|
registerDisplayListener();
|
||||||
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);
|
|
||||||
|
|
||||||
if (settings.isAudioEnabled() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (settings.isAudioEnabled() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
audioEncoder = new AudioEncoder();
|
audioEncoder = new AudioEncoder();
|
||||||
@@ -290,9 +300,117 @@ public class ScreenCastService extends Service {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
videoEncoder.requestKeyframe();
|
|
||||||
String mode = session.getTransport().getModeLabel();
|
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 {
|
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) {
|
private void sendSafe(byte type, PayloadBuilder builder) {
|
||||||
|
if (stopping.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
send(type, builder.build());
|
send(type, builder.build());
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
Log.e(TAG, "Send failed", e);
|
if (!stopping.get()) {
|
||||||
|
Log.w(TAG, "Send failed: " + e.getMessage());
|
||||||
stopping.set(true);
|
stopping.set(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private interface PayloadBuilder {
|
private interface PayloadBuilder {
|
||||||
byte[] build() throws IOException;
|
byte[] build() throws IOException;
|
||||||
@@ -331,6 +454,8 @@ public class ScreenCastService extends Service {
|
|||||||
private void releaseAll() {
|
private void releaseAll() {
|
||||||
stopping.set(true);
|
stopping.set(true);
|
||||||
casting.set(false);
|
casting.set(false);
|
||||||
|
unregisterDisplayListener();
|
||||||
|
networkFeedback = null;
|
||||||
if (virtualDisplay != null) {
|
if (virtualDisplay != null) {
|
||||||
virtualDisplay.release();
|
virtualDisplay.release();
|
||||||
virtualDisplay = null;
|
virtualDisplay = null;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import android.os.Build;
|
|||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.provider.Settings;
|
import android.provider.Settings;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
import android.widget.ArrayAdapter;
|
import android.view.WindowManager;
|
||||||
import android.widget.Button;
|
import android.widget.Button;
|
||||||
import android.widget.EditText;
|
import android.widget.EditText;
|
||||||
import android.widget.ListView;
|
import android.widget.ListView;
|
||||||
@@ -24,17 +24,12 @@ import com.foxx.androidcast.R;
|
|||||||
import com.foxx.androidcast.SettingsUi;
|
import com.foxx.androidcast.SettingsUi;
|
||||||
import com.foxx.androidcast.discovery.DiscoveryManager;
|
import com.foxx.androidcast.discovery.DiscoveryManager;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public class SenderActivity extends AppCompatActivity {
|
public class SenderActivity extends AppCompatActivity {
|
||||||
private static final int REQUEST_MEDIA_PROJECTION = 2001;
|
private static final int REQUEST_MEDIA_PROJECTION = 2001;
|
||||||
|
|
||||||
private final CastSettings castSettings = new CastSettings();
|
private final CastSettings castSettings = new CastSettings();
|
||||||
private DiscoveryManager discovery;
|
private DiscoveryManager discovery;
|
||||||
private List<DiscoveryManager.DiscoveredDevice> devices = new ArrayList<>();
|
private DeviceListAdapter deviceAdapter;
|
||||||
private ArrayAdapter<String> listAdapter;
|
|
||||||
private DiscoveryManager.DiscoveredDevice selected;
|
|
||||||
|
|
||||||
private EditText pinInput;
|
private EditText pinInput;
|
||||||
private TextView statusText;
|
private TextView statusText;
|
||||||
@@ -50,29 +45,25 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
pinInput.setText(CastConfig.DEFAULT_PIN);
|
pinInput.setText(CastConfig.DEFAULT_PIN);
|
||||||
statusText = findViewById(R.id.text_status);
|
statusText = findViewById(R.id.text_status);
|
||||||
ListView listView = findViewById(R.id.list_devices);
|
ListView listView = findViewById(R.id.list_devices);
|
||||||
listAdapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, new ArrayList<>());
|
listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
|
||||||
listView.setAdapter(listAdapter);
|
deviceAdapter = new DeviceListAdapter(this);
|
||||||
|
listView.setAdapter(deviceAdapter);
|
||||||
listView.setOnItemClickListener((parent, view, position, id) -> {
|
listView.setOnItemClickListener((parent, view, position, id) -> {
|
||||||
if (position < devices.size()) {
|
deviceAdapter.setSelectedPosition(position);
|
||||||
selected = devices.get(position);
|
DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice();
|
||||||
|
if (selected == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!castSettings.getTransport().equals(selected.transport)) {
|
if (!castSettings.getTransport().equals(selected.transport)) {
|
||||||
Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
|
Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
|
||||||
}
|
}
|
||||||
statusText.setText("Selected: " + selected.name + " (" + selected.host + ", "
|
statusText.setText(getString(R.string.device_selected, selected.name, selected.host,
|
||||||
+ selected.transport.toUpperCase() + ")");
|
selected.transport.toUpperCase()));
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
discovery = new DiscoveryManager(this);
|
discovery = new DiscoveryManager(this);
|
||||||
discovery.setListener(deviceList -> {
|
discovery.setListener(deviceList -> {
|
||||||
devices = deviceList;
|
deviceAdapter.setDevices(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();
|
|
||||||
statusText.setText(deviceList.isEmpty()
|
statusText.setText(deviceList.isEmpty()
|
||||||
? getString(R.string.searching)
|
? getString(R.string.searching)
|
||||||
: getString(R.string.found_receivers, deviceList.size()));
|
: getString(R.string.found_receivers, deviceList.size()));
|
||||||
@@ -105,6 +96,7 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void startCastFlow() {
|
private void startCastFlow() {
|
||||||
|
DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice();
|
||||||
if (selected == null) {
|
if (selected == null) {
|
||||||
Toast.makeText(this, R.string.select_receiver, Toast.LENGTH_SHORT).show();
|
Toast.makeText(this, R.string.select_receiver, Toast.LENGTH_SHORT).show();
|
||||||
return;
|
return;
|
||||||
@@ -131,6 +123,7 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
if (requestCode != REQUEST_MEDIA_PROJECTION) {
|
if (requestCode != REQUEST_MEDIA_PROJECTION) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice();
|
||||||
if (resultCode != Activity.RESULT_OK || data == null || selected == null) {
|
if (resultCode != Activity.RESULT_OK || data == null || selected == null) {
|
||||||
Toast.makeText(this, R.string.capture_denied, Toast.LENGTH_SHORT).show();
|
Toast.makeText(this, R.string.capture_denied, Toast.LENGTH_SHORT).show();
|
||||||
return;
|
return;
|
||||||
@@ -154,7 +147,8 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
} else {
|
} else {
|
||||||
startService(serviceIntent);
|
startService(serviceIntent);
|
||||||
}
|
}
|
||||||
|
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||||
Toast.makeText(this, R.string.sender_check_notification, Toast.LENGTH_LONG).show();
|
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:id="@+id/surface_playback"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:layout_gravity="center" />
|
android:layout_gravity="center"
|
||||||
|
android:keepScreenOn="true" />
|
||||||
</com.foxx.androidcast.ui.AspectRatioFrameLayout>
|
</com.foxx.androidcast.ui.AspectRatioFrameLayout>
|
||||||
|
|
||||||
|
<include
|
||||||
|
layout="@layout/overlay_stream_awaiting"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
|||||||
@@ -25,6 +25,13 @@
|
|||||||
android:hint="@string/pin_hint"
|
android:hint="@string/pin_hint"
|
||||||
android:inputType="numberPassword" />
|
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
|
<Button
|
||||||
android:id="@+id/btn_start_receiver"
|
android:id="@+id/btn_start_receiver"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#0D47A1</color>
|
<color name="ic_launcher_background">#0D47A1</color>
|
||||||
|
<color name="device_item_selected">#4D1565C0</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -39,7 +39,11 @@
|
|||||||
<string name="casting_from">Casting from %1$s</string>
|
<string name="casting_from">Casting from %1$s</string>
|
||||||
<string name="playing_resolution">Playing %1$dx%2$d</string>
|
<string name="playing_resolution">Playing %1$dx%2$d</string>
|
||||||
<string name="stop">Stop</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="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_starting">Starting receiver service…</string>
|
||||||
<string name="receiver_listening">Listening for cast</string>
|
<string name="receiver_listening">Listening for cast</string>
|
||||||
<string name="waiting_surface">Connected — open video surface…</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="error_no_intent">No cast intent</string>
|
||||||
<string name="sender_check_notification">Cast starting — see notification for status</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_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>
|
</resources>
|
||||||
|
|||||||
@@ -12,4 +12,9 @@
|
|||||||
<item name="android:windowContentOverlay">@null</item>
|
<item name="android:windowContentOverlay">@null</item>
|
||||||
<item name="android:windowBackground">@android:color/black</item>
|
<item name="android:windowBackground">@android:color/black</item>
|
||||||
</style>
|
</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>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user