mirror of
git://f0xx.org/android_cast
synced 2026-07-29 06:58:51 +03:00
snap good
This commit is contained in:
@@ -10,7 +10,8 @@ public final class CastConfig {
|
|||||||
public static final String TRANSPORT_TCP = "tcp";
|
public static final String TRANSPORT_TCP = "tcp";
|
||||||
public static final String TRANSPORT_UDP = "udp";
|
public static final String TRANSPORT_UDP = "udp";
|
||||||
|
|
||||||
public static final int VIDEO_I_FRAME_INTERVAL = 2;
|
/** Seconds between IDR frames (1 = faster recovery, slightly more bandwidth). */
|
||||||
|
public static final int VIDEO_I_FRAME_INTERVAL = 1;
|
||||||
|
|
||||||
public static final int AUDIO_SAMPLE_RATE = 44100;
|
public static final int AUDIO_SAMPLE_RATE = 44100;
|
||||||
public static final int AUDIO_CHANNEL_COUNT = 2;
|
public static final int AUDIO_CHANNEL_COUNT = 2;
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ public class CastSettings implements Serializable {
|
|||||||
public static final String EXTRA = "cast_settings";
|
public static final String EXTRA = "cast_settings";
|
||||||
|
|
||||||
public enum Quality {
|
public enum Quality {
|
||||||
LOW(800_000, 20),
|
LOW(1_500_000, 24),
|
||||||
MEDIUM(2_000_000, 24),
|
MEDIUM(5_000_000, 30),
|
||||||
HIGH(4_000_000, 30);
|
HIGH(10_000_000, 30);
|
||||||
|
|
||||||
public final int videoBitrate;
|
public final int videoBitrate;
|
||||||
public final int videoFps;
|
public final int videoFps;
|
||||||
@@ -38,7 +38,7 @@ public class CastSettings implements Serializable {
|
|||||||
|
|
||||||
private String transport = CastConfig.TRANSPORT_TCP;
|
private String transport = CastConfig.TRANSPORT_TCP;
|
||||||
private Quality quality = Quality.MEDIUM;
|
private Quality quality = Quality.MEDIUM;
|
||||||
private Resolution resolution = Resolution.FULL;
|
private Resolution resolution = Resolution.HD_720P;
|
||||||
private boolean audioEnabled;
|
private boolean audioEnabled;
|
||||||
|
|
||||||
public CastSettings() {
|
public CastSettings() {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ public final class SettingsUi {
|
|||||||
};
|
};
|
||||||
quality.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, qualities));
|
quality.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, qualities));
|
||||||
quality.setSelection(1);
|
quality.setSelection(1);
|
||||||
|
resolution.setSelection(CastSettings.Resolution.HD_720P.ordinal());
|
||||||
quality.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
|
quality.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
|
||||||
@Override
|
@Override
|
||||||
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
|
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
|
||||||
|
|||||||
@@ -87,10 +87,14 @@ public class DiscoveryManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void startAnnouncing(String deviceName, int port, String transport) {
|
public void startAnnouncing(String deviceName, int port, String transport) {
|
||||||
|
startAnnouncing(deviceName, port, transport, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void startAnnouncing(String deviceName, int port, String transport, boolean listening) {
|
||||||
if (!running.compareAndSet(false, true)) {
|
if (!running.compareAndSet(false, true)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
executor.execute(() -> announceLoop(deviceName, port, transport));
|
executor.execute(() -> announceLoop(deviceName, port, transport, listening));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void browseLoop() {
|
private void browseLoop() {
|
||||||
@@ -115,7 +119,7 @@ public class DiscoveryManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void announceLoop(String deviceName, int port, String transport) {
|
private void announceLoop(String deviceName, int port, String transport, boolean listening) {
|
||||||
acquireMulticastLock();
|
acquireMulticastLock();
|
||||||
try (DatagramSocket sock = new DatagramSocket()) {
|
try (DatagramSocket sock = new DatagramSocket()) {
|
||||||
socket = sock;
|
socket = sock;
|
||||||
@@ -126,6 +130,7 @@ public class DiscoveryManager {
|
|||||||
json.put("name", deviceName);
|
json.put("name", deviceName);
|
||||||
json.put("port", port);
|
json.put("port", port);
|
||||||
json.put("transport", transport);
|
json.put("transport", transport);
|
||||||
|
json.put("listening", listening);
|
||||||
byte[] data = json.toString().getBytes(StandardCharsets.UTF_8);
|
byte[] data = json.toString().getBytes(StandardCharsets.UTF_8);
|
||||||
InetAddress broadcast = InetAddress.getByName("255.255.255.255");
|
InetAddress broadcast = InetAddress.getByName("255.255.255.255");
|
||||||
DatagramPacket packet = new DatagramPacket(data, data.length, broadcast, CastConfig.DISCOVERY_PORT);
|
DatagramPacket packet = new DatagramPacket(data, data.length, broadcast, CastConfig.DISCOVERY_PORT);
|
||||||
@@ -151,6 +156,9 @@ public class DiscoveryManager {
|
|||||||
String name = json.optString("name", "Unknown");
|
String name = json.optString("name", "Unknown");
|
||||||
int port = json.optInt("port", CastConfig.CAST_PORT);
|
int port = json.optInt("port", CastConfig.CAST_PORT);
|
||||||
String transport = json.optString("transport", CastConfig.TRANSPORT_TCP);
|
String transport = json.optString("transport", CastConfig.TRANSPORT_TCP);
|
||||||
|
if (!json.optBoolean("listening", true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
String host = packet.getAddress().getHostAddress();
|
String host = packet.getAddress().getHostAddress();
|
||||||
String key = host + ":" + port;
|
String key = host + ":" + port;
|
||||||
DiscoveredDevice device = new DiscoveredDevice(name, host, port, transport, System.currentTimeMillis());
|
DiscoveredDevice device = new DiscoveredDevice(name, host, port, transport, System.currentTimeMillis());
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ public class CastSession {
|
|||||||
transport.listen(port);
|
transport.listen(port);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void prepareListen(int port) throws IOException {
|
||||||
|
transport.prepareListen(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void acceptClient() throws IOException {
|
||||||
|
transport.acceptClient();
|
||||||
|
}
|
||||||
|
|
||||||
public void connect(String host, int port) throws IOException {
|
public void connect(String host, int port) throws IOException {
|
||||||
transport.connect(host, port);
|
transport.connect(host, port);
|
||||||
}
|
}
|
||||||
@@ -91,6 +99,10 @@ public class CastSession {
|
|||||||
return transport.receive(timeoutMs);
|
return transport.receive(timeoutMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void releaseConnection() {
|
||||||
|
transport.releaseConnection();
|
||||||
|
}
|
||||||
|
|
||||||
public void close() {
|
public void close() {
|
||||||
transport.close();
|
transport.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ public interface CastTransport extends Closeable {
|
|||||||
/** Server: bind and wait for first peer datagram / connection. */
|
/** Server: bind and wait for first peer datagram / connection. */
|
||||||
void listen(int port) throws IOException;
|
void listen(int port) throws IOException;
|
||||||
|
|
||||||
|
/** Server: bind listen socket only (port open for inbound TCP). */
|
||||||
|
default void prepareListen(int port) throws IOException {
|
||||||
|
listen(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server: block until a client connects (TCP) or no-op (UDP). */
|
||||||
|
default void acceptClient() throws IOException {}
|
||||||
|
|
||||||
/** Client: connect to host:port. */
|
/** Client: connect to host:port. */
|
||||||
void connect(String host, int port) throws IOException;
|
void connect(String host, int port) throws IOException;
|
||||||
|
|
||||||
@@ -18,6 +26,11 @@ public interface CastTransport extends Closeable {
|
|||||||
|
|
||||||
String getModeLabel();
|
String getModeLabel();
|
||||||
|
|
||||||
|
/** Server: drop the active client socket but keep the listen socket bound. */
|
||||||
|
default void releaseConnection() {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
void close();
|
void close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,18 +25,40 @@ public class TcpCastTransport implements CastTransport {
|
|||||||
private DataInputStream in;
|
private DataInputStream in;
|
||||||
private DataOutputStream out;
|
private DataOutputStream out;
|
||||||
|
|
||||||
|
/** True once {@link #listen} has bound the server socket (port is accepting connections). */
|
||||||
|
public boolean isListening() {
|
||||||
|
return serverSocket != null && !serverSocket.isClosed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void prepareListen(int port) throws IOException {
|
||||||
|
ensureServerSocket(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void acceptClient() throws IOException {
|
||||||
|
acceptClientConnection();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void listen(int port) throws IOException {
|
public void listen(int port) throws IOException {
|
||||||
|
prepareListen(port);
|
||||||
|
acceptClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureServerSocket(int port) throws IOException {
|
||||||
|
if (serverSocket != null && !serverSocket.isClosed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
serverSocket = new ServerSocket();
|
serverSocket = new ServerSocket();
|
||||||
serverSocket.setReuseAddress(true);
|
serverSocket.setReuseAddress(true);
|
||||||
serverSocket.bind(new InetSocketAddress(InetAddress.getByName("0.0.0.0"), port));
|
serverSocket.bind(new InetSocketAddress(InetAddress.getByName("0.0.0.0"), port));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void acceptClientConnection() throws IOException {
|
||||||
|
releaseConnection();
|
||||||
socket = serverSocket.accept();
|
socket = serverSocket.accept();
|
||||||
socket.setTcpNoDelay(true);
|
socket.setTcpNoDelay(true);
|
||||||
try {
|
|
||||||
serverSocket.close();
|
|
||||||
} catch (IOException ignored) {
|
|
||||||
}
|
|
||||||
serverSocket = null;
|
|
||||||
openStreams();
|
openStreams();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,29 +138,38 @@ public class TcpCastTransport implements CastTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void releaseConnection() {
|
||||||
synchronized (sendLock) {
|
synchronized (sendLock) {
|
||||||
synchronized (receiveLock) {
|
synchronized (receiveLock) {
|
||||||
closeSockets();
|
closeClientOnly();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void closeSockets() {
|
@Override
|
||||||
|
public void close() {
|
||||||
|
synchronized (sendLock) {
|
||||||
|
synchronized (receiveLock) {
|
||||||
|
closeClientOnly();
|
||||||
|
if (serverSocket != null) {
|
||||||
|
try {
|
||||||
|
serverSocket.close();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
serverSocket = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeClientOnly() {
|
||||||
try {
|
try {
|
||||||
if (socket != null) {
|
if (socket != null) {
|
||||||
socket.close();
|
socket.close();
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
if (serverSocket != null && !serverSocket.isClosed()) {
|
|
||||||
serverSocket.close();
|
|
||||||
}
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
}
|
|
||||||
socket = null;
|
socket = null;
|
||||||
serverSocket = null;
|
|
||||||
bufferedIn = null;
|
bufferedIn = null;
|
||||||
in = null;
|
in = null;
|
||||||
out = null;
|
out = null;
|
||||||
|
|||||||
@@ -24,12 +24,19 @@ public class UdpCastTransport implements CastTransport {
|
|||||||
private final Map<Integer, Reassembly> pending = new ConcurrentHashMap<>();
|
private final Map<Integer, Reassembly> pending = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void listen(int port) throws IOException {
|
public void prepareListen(int port) throws IOException {
|
||||||
|
if (socket != null && !socket.isClosed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
socket = new DatagramSocket(null);
|
socket = new DatagramSocket(null);
|
||||||
socket.setReuseAddress(true);
|
socket.setReuseAddress(true);
|
||||||
socket.bind(new InetSocketAddress(port));
|
socket.bind(new InetSocketAddress(port));
|
||||||
socket.setSoTimeout(500);
|
socket.setSoTimeout(500);
|
||||||
// peer set on first received packet
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void listen(int port) throws IOException {
|
||||||
|
prepareListen(port);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -160,6 +167,11 @@ public class UdpCastTransport implements CastTransport {
|
|||||||
return "UDP";
|
return "UDP";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void releaseConnection() {
|
||||||
|
peer = null;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
if (socket != null) {
|
if (socket != null) {
|
||||||
|
|||||||
@@ -177,10 +177,12 @@ public class ReceiverCastService extends Service {
|
|||||||
videoDecoder = new VideoDecoder();
|
videoDecoder = new VideoDecoder();
|
||||||
audioDecoder = new AudioDecoder();
|
audioDecoder = new AudioDecoder();
|
||||||
|
|
||||||
discovery = new DiscoveryManager(this);
|
|
||||||
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
|
||||||
|
public void onListening() {
|
||||||
|
mainHandler.post(ReceiverCastService.this::startDiscoveryAnnouncing);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onStatus(String s) {
|
public void onStatus(String s) {
|
||||||
updateStatus(s);
|
updateStatus(s);
|
||||||
@@ -447,6 +449,16 @@ public class ReceiverCastService extends Service {
|
|||||||
playbackLaunched = false;
|
playbackLaunched = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void startDiscoveryAnnouncing() {
|
||||||
|
if (discovery != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
discovery = new DiscoveryManager(this);
|
||||||
|
discovery.startAnnouncing(android.os.Build.MODEL, com.foxx.androidcast.CastConfig.CAST_PORT,
|
||||||
|
settings.getTransport(), true);
|
||||||
|
Log.i(TAG, "Discovery announcing on port " + com.foxx.androidcast.CastConfig.CAST_PORT);
|
||||||
|
}
|
||||||
|
|
||||||
private void stopSessionOnly() {
|
private void stopSessionOnly() {
|
||||||
if (discovery != null) {
|
if (discovery != null) {
|
||||||
discovery.stop();
|
discovery.stop();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.foxx.androidcast.CastConfig;
|
|||||||
import com.foxx.androidcast.CastSettings;
|
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.CastTransport;
|
||||||
import com.foxx.androidcast.network.CastTransportFactory;
|
import com.foxx.androidcast.network.CastTransportFactory;
|
||||||
import com.foxx.androidcast.network.control.NetworkFeedbackManager;
|
import com.foxx.androidcast.network.control.NetworkFeedbackManager;
|
||||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||||
@@ -35,6 +36,9 @@ public class ReceiverSession {
|
|||||||
|
|
||||||
void onDisconnected();
|
void onDisconnected();
|
||||||
|
|
||||||
|
/** TCP/UDP listen socket is bound; safe to advertise on the LAN. */
|
||||||
|
void onListening();
|
||||||
|
|
||||||
void onNetworkAdviceApplied();
|
void onNetworkAdviceApplied();
|
||||||
|
|
||||||
void onPeerNetworkStats(NetworkStatsSnapshot stats);
|
void onPeerNetworkStats(NetworkStatsSnapshot stats);
|
||||||
@@ -47,6 +51,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 CastTransport listenTransport;
|
||||||
private NetworkFeedbackManager networkFeedback;
|
private NetworkFeedbackManager networkFeedback;
|
||||||
|
|
||||||
public ReceiverSession(String pin, CastSettings localSettings, Listener listener) {
|
public ReceiverSession(String pin, CastSettings localSettings, Listener listener) {
|
||||||
@@ -68,6 +73,10 @@ public class ReceiverSession {
|
|||||||
session.close();
|
session.close();
|
||||||
session = null;
|
session = null;
|
||||||
}
|
}
|
||||||
|
if (listenTransport != null) {
|
||||||
|
listenTransport.close();
|
||||||
|
listenTransport = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void sendAudioConsent(boolean accept) {
|
public void sendAudioConsent(boolean accept) {
|
||||||
@@ -84,11 +93,18 @@ public class ReceiverSession {
|
|||||||
private void runServer() {
|
private void runServer() {
|
||||||
while (running.get()) {
|
while (running.get()) {
|
||||||
try {
|
try {
|
||||||
session = new CastSession(CastTransportFactory.create(localSettings));
|
if (listenTransport == null) {
|
||||||
networkFeedback = new NetworkFeedbackManager(session, false);
|
listenTransport = CastTransportFactory.create(localSettings);
|
||||||
session.listen(CastConfig.CAST_PORT);
|
session = new CastSession(listenTransport);
|
||||||
postStatus("Listening (" + session.getTransport().getModeLabel() + ") port "
|
session.prepareListen(CastConfig.CAST_PORT);
|
||||||
|
listener.onListening();
|
||||||
|
postStatus("Listening (" + listenTransport.getModeLabel() + ") port "
|
||||||
+ CastConfig.CAST_PORT);
|
+ CastConfig.CAST_PORT);
|
||||||
|
} else if (session == null) {
|
||||||
|
session = new CastSession(listenTransport);
|
||||||
|
}
|
||||||
|
networkFeedback = new NetworkFeedbackManager(session, false);
|
||||||
|
session.acceptClient();
|
||||||
CastSession.HandshakeResult hs = session.serverHandshake(pin, localSettings);
|
CastSession.HandshakeResult hs = session.serverHandshake(pin, localSettings);
|
||||||
listener.onAuthenticated(hs.senderName, hs.settings);
|
listener.onAuthenticated(hs.senderName, hs.settings);
|
||||||
postStatus("Streaming from " + hs.senderName);
|
postStatus("Streaming from " + hs.senderName);
|
||||||
@@ -115,8 +131,7 @@ public class ReceiverSession {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
session.close();
|
session.releaseConnection();
|
||||||
session = null;
|
|
||||||
}
|
}
|
||||||
networkFeedback = null;
|
networkFeedback = null;
|
||||||
listener.onDisconnected();
|
listener.onDisconnected();
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ public final class StreamMetrics {
|
|||||||
private long audioBytes;
|
private long audioBytes;
|
||||||
private long renderedFrames;
|
private long renderedFrames;
|
||||||
private long windowVideoBytes;
|
private long windowVideoBytes;
|
||||||
|
private long windowVideoPackets;
|
||||||
private long windowStartMs;
|
private long windowStartMs;
|
||||||
|
private float lastSecondInFps;
|
||||||
private long lastVideoPacketMs;
|
private long lastVideoPacketMs;
|
||||||
private long lastRenderedFrameMs;
|
private long lastRenderedFrameMs;
|
||||||
private long firstVideoPacketMs;
|
private long firstVideoPacketMs;
|
||||||
@@ -29,7 +31,9 @@ public final class StreamMetrics {
|
|||||||
audioBytes = 0;
|
audioBytes = 0;
|
||||||
renderedFrames = 0;
|
renderedFrames = 0;
|
||||||
windowVideoBytes = 0;
|
windowVideoBytes = 0;
|
||||||
|
windowVideoPackets = 0;
|
||||||
windowStartMs = 0;
|
windowStartMs = 0;
|
||||||
|
lastSecondInFps = 0f;
|
||||||
lastVideoPacketMs = 0;
|
lastVideoPacketMs = 0;
|
||||||
lastRenderedFrameMs = 0;
|
lastRenderedFrameMs = 0;
|
||||||
firstVideoPacketMs = 0;
|
firstVideoPacketMs = 0;
|
||||||
@@ -50,6 +54,14 @@ public final class StreamMetrics {
|
|||||||
if (windowStartMs == 0) {
|
if (windowStartMs == 0) {
|
||||||
windowStartMs = now;
|
windowStartMs = now;
|
||||||
}
|
}
|
||||||
|
long windowElapsed = now - windowStartMs;
|
||||||
|
if (windowElapsed >= 1000) {
|
||||||
|
lastSecondInFps = windowVideoPackets;
|
||||||
|
windowVideoPackets = 0;
|
||||||
|
windowVideoBytes = 0;
|
||||||
|
windowStartMs = now;
|
||||||
|
}
|
||||||
|
windowVideoPackets++;
|
||||||
windowVideoBytes += bytes;
|
windowVideoBytes += bytes;
|
||||||
if (firstVideoPacketMs == 0) {
|
if (firstVideoPacketMs == 0) {
|
||||||
firstVideoPacketMs = now;
|
firstVideoPacketMs = now;
|
||||||
@@ -83,7 +95,7 @@ public final class StreamMetrics {
|
|||||||
public synchronized String format(String transport, int streamW, int streamH, int renderW, int renderH,
|
public synchronized String format(String transport, int streamW, int streamH, int renderW, int renderH,
|
||||||
boolean idle) {
|
boolean idle) {
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
float inFps = computeFps(videoPackets, firstVideoPacketMs, now);
|
float inFps = lastSecondInFps > 0 ? lastSecondInFps : computeFps(videoPackets, firstVideoPacketMs, now);
|
||||||
float renderFps = computeFps(renderedFrames, firstVideoPacketMs, lastRenderedFrameMs);
|
float renderFps = computeFps(renderedFrames, firstVideoPacketMs, lastRenderedFrameMs);
|
||||||
int localKbps = computeLocalKbps(now);
|
int localKbps = computeLocalKbps(now);
|
||||||
long idleMs = lastVideoPacketMs > 0 ? now - lastVideoPacketMs : -1;
|
long idleMs = lastVideoPacketMs > 0 ? now - lastVideoPacketMs : -1;
|
||||||
|
|||||||
127
app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java
Normal file
127
app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package com.foxx.androidcast.sender;
|
||||||
|
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.network.CastProtocol;
|
||||||
|
import com.foxx.androidcast.network.CastSession;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.concurrent.LinkedBlockingQueue;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
/** Background TCP/UDP send queue so encoding is not blocked by network I/O. */
|
||||||
|
public final class CastSendPump {
|
||||||
|
private static final String TAG = "CastSendPump";
|
||||||
|
private static final int MAX_QUEUE = 48;
|
||||||
|
private static final int MAX_P_FRAMES_BACKLOG = 36;
|
||||||
|
|
||||||
|
private final LinkedBlockingQueue<Outgoing> queue = new LinkedBlockingQueue<>(MAX_QUEUE);
|
||||||
|
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||||
|
private Thread thread;
|
||||||
|
private CastSession session;
|
||||||
|
private Runnable onSendFailed;
|
||||||
|
|
||||||
|
public void start(CastSession session, Runnable onSendFailed) {
|
||||||
|
this.session = session;
|
||||||
|
this.onSendFailed = onSendFailed;
|
||||||
|
if (!running.compareAndSet(false, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thread = new Thread(this::run, "CastSendPump");
|
||||||
|
thread.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
running.set(false);
|
||||||
|
if (thread != null) {
|
||||||
|
thread.interrupt();
|
||||||
|
try {
|
||||||
|
thread.join(1500);
|
||||||
|
} catch (InterruptedException ignored) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
thread = null;
|
||||||
|
}
|
||||||
|
queue.clear();
|
||||||
|
session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void enqueueVideo(long ptsUs, boolean keyFrame, byte[] annexBReadyPayload) {
|
||||||
|
if (!running.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Outgoing item = Outgoing.video(ptsUs, keyFrame, annexBReadyPayload);
|
||||||
|
if (keyFrame) {
|
||||||
|
queue.offer(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (queue.size() >= MAX_P_FRAMES_BACKLOG) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!queue.offer(item)) {
|
||||||
|
queue.poll();
|
||||||
|
queue.offer(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void enqueue(byte type, byte[] payload) {
|
||||||
|
if (!running.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Outgoing item = Outgoing.raw(type, payload);
|
||||||
|
while (running.get() && !queue.offer(item)) {
|
||||||
|
queue.poll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void run() {
|
||||||
|
while (running.get()) {
|
||||||
|
try {
|
||||||
|
Outgoing item = queue.poll(200, TimeUnit.MILLISECONDS);
|
||||||
|
if (item == null || session == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
session.send(item.type, item.buildPayload());
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
if (!running.get()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.w(TAG, "Send failed: " + e.getMessage());
|
||||||
|
if (onSendFailed != null) {
|
||||||
|
onSendFailed.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Outgoing {
|
||||||
|
final byte type;
|
||||||
|
final byte[] payload;
|
||||||
|
final long ptsUs;
|
||||||
|
final boolean keyFrame;
|
||||||
|
|
||||||
|
private Outgoing(byte type, byte[] payload, long ptsUs, boolean keyFrame) {
|
||||||
|
this.type = type;
|
||||||
|
this.payload = payload;
|
||||||
|
this.ptsUs = ptsUs;
|
||||||
|
this.keyFrame = keyFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Outgoing raw(byte type, byte[] payload) {
|
||||||
|
return new Outgoing(type, payload, 0, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Outgoing video(long ptsUs, boolean keyFrame, byte[] payload) {
|
||||||
|
return new Outgoing(CastProtocol.MSG_VIDEO_FRAME, payload, ptsUs, keyFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] buildPayload() throws IOException {
|
||||||
|
if (type != CastProtocol.MSG_VIDEO_FRAME) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
return CastProtocol.videoFramePayload(ptsUs, keyFrame, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ public class ScreenCastService extends Service {
|
|||||||
private AudioEncoder audioEncoder;
|
private AudioEncoder audioEncoder;
|
||||||
private AudioCapture audioCapture;
|
private AudioCapture audioCapture;
|
||||||
private CastSession session;
|
private CastSession session;
|
||||||
|
private CastSendPump sendPump;
|
||||||
private NetworkFeedbackManager networkFeedback;
|
private NetworkFeedbackManager networkFeedback;
|
||||||
private PowerManager.WakeLock wakeLock;
|
private PowerManager.WakeLock wakeLock;
|
||||||
private Thread ioThread;
|
private Thread ioThread;
|
||||||
@@ -158,6 +159,8 @@ public class ScreenCastService extends Service {
|
|||||||
connectWithRetry(host, port, settings);
|
connectWithRetry(host, port, settings);
|
||||||
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);
|
||||||
|
sendPump = new CastSendPump();
|
||||||
|
sendPump.start(session, () -> stopping.set(true));
|
||||||
networkFeedback = new NetworkFeedbackManager(session, true);
|
networkFeedback = new NetworkFeedbackManager(session, true);
|
||||||
casting.set(true);
|
casting.set(true);
|
||||||
activeSettings = settings;
|
activeSettings = settings;
|
||||||
@@ -175,7 +178,6 @@ public class ScreenCastService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Thread.sleep(400);
|
|
||||||
} catch (java.net.SocketException e) {
|
} catch (java.net.SocketException e) {
|
||||||
if (!stopping.get()) {
|
if (!stopping.get()) {
|
||||||
Log.i(TAG, "Connection lost: " + e.getMessage());
|
Log.i(TAG, "Connection lost: " + e.getMessage());
|
||||||
@@ -227,7 +229,11 @@ public class ScreenCastService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new IOException("Could not connect to " + host + ":" + port, last);
|
String detail = last != null && last.getMessage() != null ? last.getMessage() : "";
|
||||||
|
if (detail.contains("ECONNREFUSED")) {
|
||||||
|
throw new IOException(getString(R.string.error_receiver_refused, host, port), last);
|
||||||
|
}
|
||||||
|
throw new IOException(getString(R.string.error_connect_failed, host, port), last);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startCaptureOnMainThread(int resultCode, Intent data, CastSettings settings) throws IOException {
|
private void startCaptureOnMainThread(int resultCode, Intent data, CastSettings settings) throws IOException {
|
||||||
@@ -237,8 +243,12 @@ public class ScreenCastService extends Service {
|
|||||||
mainHandler.post(() -> {
|
mainHandler.post(() -> {
|
||||||
try {
|
try {
|
||||||
startCaptureOnMainThread(resultCode, data, settings);
|
startCaptureOnMainThread(resultCode, data, settings);
|
||||||
} catch (IOException e) {
|
} catch (Exception e) {
|
||||||
error.set(e);
|
if (e instanceof IOException) {
|
||||||
|
error.set((IOException) e);
|
||||||
|
} else {
|
||||||
|
error.set(new IOException(e.getMessage() != null ? e.getMessage() : e.toString(), e));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
latch.countDown();
|
latch.countDown();
|
||||||
}
|
}
|
||||||
@@ -339,8 +349,9 @@ public class ScreenCastService extends Service {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
|
public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
|
||||||
sendSafe(CastProtocol.MSG_VIDEO_FRAME,
|
if (sendPump != null) {
|
||||||
() -> CastProtocol.videoFramePayload(ptsUs, keyFrame, frameData));
|
sendPump.enqueueVideo(ptsUs, keyFrame, frameData);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -421,22 +432,15 @@ public class ScreenCastService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void send(byte type, byte[] payloadBytes) throws IOException {
|
|
||||||
if (stopping.get() || session == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
session.send(type, payloadBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void sendSafe(byte type, PayloadBuilder builder) {
|
private void sendSafe(byte type, PayloadBuilder builder) {
|
||||||
if (stopping.get()) {
|
if (stopping.get() || sendPump == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
send(type, builder.build());
|
sendPump.enqueue(type, builder.build());
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
if (!stopping.get()) {
|
if (!stopping.get()) {
|
||||||
Log.w(TAG, "Send failed: " + e.getMessage());
|
Log.w(TAG, "Payload build failed: " + e.getMessage());
|
||||||
stopping.set(true);
|
stopping.set(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -484,6 +488,10 @@ public class ScreenCastService extends Service {
|
|||||||
projection.stop();
|
projection.stop();
|
||||||
projection = null;
|
projection = null;
|
||||||
}
|
}
|
||||||
|
if (sendPump != null) {
|
||||||
|
sendPump.stop();
|
||||||
|
sendPump = null;
|
||||||
|
}
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
try {
|
try {
|
||||||
session.send(CastProtocol.MSG_GOODBYE, new byte[0]);
|
session.send(CastProtocol.MSG_GOODBYE, new byte[0]);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.foxx.androidcast.sender;
|
|||||||
import android.media.MediaCodec;
|
import android.media.MediaCodec;
|
||||||
import android.media.MediaCodecInfo;
|
import android.media.MediaCodecInfo;
|
||||||
import android.media.MediaFormat;
|
import android.media.MediaFormat;
|
||||||
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.Surface;
|
import android.view.Surface;
|
||||||
@@ -13,7 +14,7 @@ import com.foxx.androidcast.CastSettings;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
|
|
||||||
/** H.264 encoder using MediaCodec (AVC). */
|
/** H.264 encoder using MediaCodec (AVC), tuned for low-latency screen cast. */
|
||||||
public class VideoEncoder {
|
public class VideoEncoder {
|
||||||
private static final String TAG = "VideoEncoder";
|
private static final String TAG = "VideoEncoder";
|
||||||
private static final String MIME = MediaFormat.MIMETYPE_VIDEO_AVC;
|
private static final String MIME = MediaFormat.MIMETYPE_VIDEO_AVC;
|
||||||
@@ -37,22 +38,67 @@ public class VideoEncoder {
|
|||||||
this.height = height;
|
this.height = height;
|
||||||
this.callback = callback;
|
this.callback = callback;
|
||||||
|
|
||||||
MediaFormat format = MediaFormat.createVideoFormat(MIME, width, height);
|
MediaFormat tuned = buildFormat(width, height, settings, true);
|
||||||
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
|
MediaFormat basic = buildFormat(width, height, settings, false);
|
||||||
format.setInteger(MediaFormat.KEY_BIT_RATE, settings.getVideoBitrate());
|
|
||||||
format.setInteger(MediaFormat.KEY_FRAME_RATE, settings.getVideoFps());
|
|
||||||
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, CastConfig.VIDEO_I_FRAME_INTERVAL);
|
|
||||||
|
|
||||||
codec = MediaCodec.createEncoderByType(MIME);
|
codec = MediaCodec.createEncoderByType(MIME);
|
||||||
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
|
if (!tryConfigure(codec, tuned)) {
|
||||||
|
Log.w(TAG, "Tuned encoder format rejected; retrying with basic format");
|
||||||
|
codec.release();
|
||||||
|
codec = MediaCodec.createEncoderByType(MIME);
|
||||||
|
if (!tryConfigure(codec, basic)) {
|
||||||
|
codec.release();
|
||||||
|
codec = null;
|
||||||
|
throw new IOException("H.264 encoder configure failed for " + width + "x" + height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
inputSurface = codec.createInputSurface();
|
inputSurface = codec.createInputSurface();
|
||||||
codec.start();
|
codec.start();
|
||||||
running = true;
|
running = true;
|
||||||
drainThread = new Thread(this::drainLoop, "VideoEncoderDrain");
|
drainThread = new Thread(this::drainLoop, "VideoEncoderDrain");
|
||||||
drainThread.start();
|
drainThread.start();
|
||||||
|
Log.i(TAG, "Encoder " + width + "x" + height + " @ " + settings.getVideoFps() + "fps "
|
||||||
|
+ (settings.getVideoBitrate() / 1000) + "kbps");
|
||||||
return inputSurface;
|
return inputSurface;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static MediaFormat buildFormat(int width, int height, CastSettings settings, boolean tuned) {
|
||||||
|
MediaFormat format = MediaFormat.createVideoFormat(MIME, width, height);
|
||||||
|
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
|
||||||
|
format.setInteger(MediaFormat.KEY_BIT_RATE, settings.getVideoBitrate());
|
||||||
|
format.setInteger(MediaFormat.KEY_FRAME_RATE, settings.getVideoFps());
|
||||||
|
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, CastConfig.VIDEO_I_FRAME_INTERVAL);
|
||||||
|
if (!tuned) {
|
||||||
|
return format;
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
format.setInteger(MediaFormat.KEY_BITRATE_MODE,
|
||||||
|
MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR);
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
format.setInteger(MediaFormat.KEY_PRIORITY, 0);
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
format.setInteger(MediaFormat.KEY_OPERATING_RATE, settings.getVideoFps());
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
format.setInteger(MediaFormat.KEY_LOW_LATENCY, 1);
|
||||||
|
}
|
||||||
|
return format;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean tryConfigure(MediaCodec codec, MediaFormat format) {
|
||||||
|
try {
|
||||||
|
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
|
||||||
|
Log.i(TAG, "Using encoder format: " + format);
|
||||||
|
return true;
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||||
|
Log.w(TAG, "Encoder configure rejected: " + format + " — " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void requestKeyframe() {
|
public void requestKeyframe() {
|
||||||
if (codec == null) {
|
if (codec == null) {
|
||||||
return;
|
return;
|
||||||
@@ -93,7 +139,7 @@ public class VideoEncoder {
|
|||||||
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
||||||
boolean configSent = false;
|
boolean configSent = false;
|
||||||
while (running) {
|
while (running) {
|
||||||
int index = codec.dequeueOutputBuffer(info, 10_000);
|
int index = codec.dequeueOutputBuffer(info, 5_000);
|
||||||
if (index == MediaCodec.INFO_TRY_AGAIN_LATER) {
|
if (index == MediaCodec.INFO_TRY_AGAIN_LATER) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,9 @@
|
|||||||
<string name="continue_video_only">Continue (video only)</string>
|
<string name="continue_video_only">Continue (video only)</string>
|
||||||
<string name="transport_tcp">TCP (reliable)</string>
|
<string name="transport_tcp">TCP (reliable)</string>
|
||||||
<string name="transport_udp">UDP (best effort)</string>
|
<string name="transport_udp">UDP (best effort)</string>
|
||||||
<string name="quality_low">Low (~0.8 Mbps)</string>
|
<string name="quality_low">Low (~1.5 Mbps, 24 fps)</string>
|
||||||
<string name="quality_medium">Medium (~2 Mbps)</string>
|
<string name="quality_medium">Medium (~5 Mbps, 30 fps) — recommended</string>
|
||||||
<string name="quality_high">High (~4 Mbps)</string>
|
<string name="quality_high">High (~10 Mbps, 30 fps)</string>
|
||||||
<string name="resolution_full">Full display</string>
|
<string name="resolution_full">Full display</string>
|
||||||
<string name="resolution_75">75% scale</string>
|
<string name="resolution_75">75% scale</string>
|
||||||
<string name="resolution_half">50% scale</string>
|
<string name="resolution_half">50% scale</string>
|
||||||
@@ -56,6 +56,8 @@
|
|||||||
<string name="error_projection_data">Screen capture data missing (retry cast)</string>
|
<string name="error_projection_data">Screen capture data missing (retry cast)</string>
|
||||||
<string name="error_missing_params">Missing host, PIN, or settings</string>
|
<string name="error_missing_params">Missing host, PIN, or settings</string>
|
||||||
<string name="error_no_intent">No cast intent</string>
|
<string name="error_no_intent">No cast intent</string>
|
||||||
|
<string name="error_receiver_refused">Receiver not listening at %1$s:%2$d — on the tablet tap Start listening, then Refresh on this phone.</string>
|
||||||
|
<string name="error_connect_failed">Could not connect to %1$s:%2$d</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="playback_rendering">Video playing</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user