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

View File

@@ -48,11 +48,13 @@
<service <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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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