mirror of
git://f0xx.org/android_cast
synced 2026-07-29 06:18:42 +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_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_CHANNEL_COUNT = 2;
|
||||
|
||||
@@ -7,9 +7,9 @@ public class CastSettings implements Serializable {
|
||||
public static final String EXTRA = "cast_settings";
|
||||
|
||||
public enum Quality {
|
||||
LOW(800_000, 20),
|
||||
MEDIUM(2_000_000, 24),
|
||||
HIGH(4_000_000, 30);
|
||||
LOW(1_500_000, 24),
|
||||
MEDIUM(5_000_000, 30),
|
||||
HIGH(10_000_000, 30);
|
||||
|
||||
public final int videoBitrate;
|
||||
public final int videoFps;
|
||||
@@ -38,7 +38,7 @@ public class CastSettings implements Serializable {
|
||||
|
||||
private String transport = CastConfig.TRANSPORT_TCP;
|
||||
private Quality quality = Quality.MEDIUM;
|
||||
private Resolution resolution = Resolution.FULL;
|
||||
private Resolution resolution = Resolution.HD_720P;
|
||||
private boolean audioEnabled;
|
||||
|
||||
public CastSettings() {
|
||||
|
||||
@@ -44,6 +44,7 @@ public final class SettingsUi {
|
||||
};
|
||||
quality.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, qualities));
|
||||
quality.setSelection(1);
|
||||
resolution.setSelection(CastSettings.Resolution.HD_720P.ordinal());
|
||||
quality.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
|
||||
@Override
|
||||
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) {
|
||||
startAnnouncing(deviceName, port, transport, true);
|
||||
}
|
||||
|
||||
public void startAnnouncing(String deviceName, int port, String transport, boolean listening) {
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
executor.execute(() -> announceLoop(deviceName, port, transport));
|
||||
executor.execute(() -> announceLoop(deviceName, port, transport, listening));
|
||||
}
|
||||
|
||||
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();
|
||||
try (DatagramSocket sock = new DatagramSocket()) {
|
||||
socket = sock;
|
||||
@@ -126,6 +130,7 @@ public class DiscoveryManager {
|
||||
json.put("name", deviceName);
|
||||
json.put("port", port);
|
||||
json.put("transport", transport);
|
||||
json.put("listening", listening);
|
||||
byte[] data = json.toString().getBytes(StandardCharsets.UTF_8);
|
||||
InetAddress broadcast = InetAddress.getByName("255.255.255.255");
|
||||
DatagramPacket packet = new DatagramPacket(data, data.length, broadcast, CastConfig.DISCOVERY_PORT);
|
||||
@@ -151,6 +156,9 @@ public class DiscoveryManager {
|
||||
String name = json.optString("name", "Unknown");
|
||||
int port = json.optInt("port", CastConfig.CAST_PORT);
|
||||
String transport = json.optString("transport", CastConfig.TRANSPORT_TCP);
|
||||
if (!json.optBoolean("listening", true)) {
|
||||
return;
|
||||
}
|
||||
String host = packet.getAddress().getHostAddress();
|
||||
String key = host + ":" + port;
|
||||
DiscoveredDevice device = new DiscoveredDevice(name, host, port, transport, System.currentTimeMillis());
|
||||
|
||||
@@ -30,6 +30,14 @@ public class CastSession {
|
||||
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 {
|
||||
transport.connect(host, port);
|
||||
}
|
||||
@@ -91,6 +99,10 @@ public class CastSession {
|
||||
return transport.receive(timeoutMs);
|
||||
}
|
||||
|
||||
public void releaseConnection() {
|
||||
transport.releaseConnection();
|
||||
}
|
||||
|
||||
public void close() {
|
||||
transport.close();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,14 @@ public interface CastTransport extends Closeable {
|
||||
/** Server: bind and wait for first peer datagram / connection. */
|
||||
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. */
|
||||
void connect(String host, int port) throws IOException;
|
||||
|
||||
@@ -18,6 +26,11 @@ public interface CastTransport extends Closeable {
|
||||
|
||||
String getModeLabel();
|
||||
|
||||
/** Server: drop the active client socket but keep the listen socket bound. */
|
||||
default void releaseConnection() {
|
||||
close();
|
||||
}
|
||||
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
|
||||
@@ -25,18 +25,40 @@ public class TcpCastTransport implements CastTransport {
|
||||
private DataInputStream in;
|
||||
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
|
||||
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.setReuseAddress(true);
|
||||
serverSocket.bind(new InetSocketAddress(InetAddress.getByName("0.0.0.0"), port));
|
||||
}
|
||||
|
||||
private void acceptClientConnection() throws IOException {
|
||||
releaseConnection();
|
||||
socket = serverSocket.accept();
|
||||
socket.setTcpNoDelay(true);
|
||||
try {
|
||||
serverSocket.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
serverSocket = null;
|
||||
openStreams();
|
||||
}
|
||||
|
||||
@@ -116,29 +138,38 @@ public class TcpCastTransport implements CastTransport {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
public void releaseConnection() {
|
||||
synchronized (sendLock) {
|
||||
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 {
|
||||
if (socket != null) {
|
||||
socket.close();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
if (serverSocket != null && !serverSocket.isClosed()) {
|
||||
serverSocket.close();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
socket = null;
|
||||
serverSocket = null;
|
||||
bufferedIn = null;
|
||||
in = null;
|
||||
out = null;
|
||||
|
||||
@@ -24,12 +24,19 @@ public class UdpCastTransport implements CastTransport {
|
||||
private final Map<Integer, Reassembly> pending = new ConcurrentHashMap<>();
|
||||
|
||||
@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.setReuseAddress(true);
|
||||
socket.bind(new InetSocketAddress(port));
|
||||
socket.setSoTimeout(500);
|
||||
// peer set on first received packet
|
||||
}
|
||||
|
||||
@Override
|
||||
public void listen(int port) throws IOException {
|
||||
prepareListen(port);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -160,6 +167,11 @@ public class UdpCastTransport implements CastTransport {
|
||||
return "UDP";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void releaseConnection() {
|
||||
peer = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (socket != null) {
|
||||
|
||||
@@ -177,10 +177,12 @@ public class ReceiverCastService extends Service {
|
||||
videoDecoder = new VideoDecoder();
|
||||
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() {
|
||||
@Override
|
||||
public void onListening() {
|
||||
mainHandler.post(ReceiverCastService.this::startDiscoveryAnnouncing);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStatus(String s) {
|
||||
updateStatus(s);
|
||||
@@ -447,6 +449,16 @@ public class ReceiverCastService extends Service {
|
||||
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() {
|
||||
if (discovery != null) {
|
||||
discovery.stop();
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.network.CastProtocol;
|
||||
import com.foxx.androidcast.network.CastSession;
|
||||
import com.foxx.androidcast.network.CastTransport;
|
||||
import com.foxx.androidcast.network.CastTransportFactory;
|
||||
import com.foxx.androidcast.network.control.NetworkFeedbackManager;
|
||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
@@ -35,6 +36,9 @@ public class ReceiverSession {
|
||||
|
||||
void onDisconnected();
|
||||
|
||||
/** TCP/UDP listen socket is bound; safe to advertise on the LAN. */
|
||||
void onListening();
|
||||
|
||||
void onNetworkAdviceApplied();
|
||||
|
||||
void onPeerNetworkStats(NetworkStatsSnapshot stats);
|
||||
@@ -47,6 +51,7 @@ public class ReceiverSession {
|
||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||
|
||||
private CastSession session;
|
||||
private CastTransport listenTransport;
|
||||
private NetworkFeedbackManager networkFeedback;
|
||||
|
||||
public ReceiverSession(String pin, CastSettings localSettings, Listener listener) {
|
||||
@@ -68,6 +73,10 @@ public class ReceiverSession {
|
||||
session.close();
|
||||
session = null;
|
||||
}
|
||||
if (listenTransport != null) {
|
||||
listenTransport.close();
|
||||
listenTransport = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void sendAudioConsent(boolean accept) {
|
||||
@@ -84,11 +93,18 @@ public class ReceiverSession {
|
||||
private void runServer() {
|
||||
while (running.get()) {
|
||||
try {
|
||||
session = new CastSession(CastTransportFactory.create(localSettings));
|
||||
networkFeedback = new NetworkFeedbackManager(session, false);
|
||||
session.listen(CastConfig.CAST_PORT);
|
||||
postStatus("Listening (" + session.getTransport().getModeLabel() + ") port "
|
||||
if (listenTransport == null) {
|
||||
listenTransport = CastTransportFactory.create(localSettings);
|
||||
session = new CastSession(listenTransport);
|
||||
session.prepareListen(CastConfig.CAST_PORT);
|
||||
listener.onListening();
|
||||
postStatus("Listening (" + listenTransport.getModeLabel() + ") 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);
|
||||
listener.onAuthenticated(hs.senderName, hs.settings);
|
||||
postStatus("Streaming from " + hs.senderName);
|
||||
@@ -115,8 +131,7 @@ public class ReceiverSession {
|
||||
}
|
||||
} finally {
|
||||
if (session != null) {
|
||||
session.close();
|
||||
session = null;
|
||||
session.releaseConnection();
|
||||
}
|
||||
networkFeedback = null;
|
||||
listener.onDisconnected();
|
||||
|
||||
@@ -12,7 +12,9 @@ public final class StreamMetrics {
|
||||
private long audioBytes;
|
||||
private long renderedFrames;
|
||||
private long windowVideoBytes;
|
||||
private long windowVideoPackets;
|
||||
private long windowStartMs;
|
||||
private float lastSecondInFps;
|
||||
private long lastVideoPacketMs;
|
||||
private long lastRenderedFrameMs;
|
||||
private long firstVideoPacketMs;
|
||||
@@ -29,7 +31,9 @@ public final class StreamMetrics {
|
||||
audioBytes = 0;
|
||||
renderedFrames = 0;
|
||||
windowVideoBytes = 0;
|
||||
windowVideoPackets = 0;
|
||||
windowStartMs = 0;
|
||||
lastSecondInFps = 0f;
|
||||
lastVideoPacketMs = 0;
|
||||
lastRenderedFrameMs = 0;
|
||||
firstVideoPacketMs = 0;
|
||||
@@ -50,6 +54,14 @@ public final class StreamMetrics {
|
||||
if (windowStartMs == 0) {
|
||||
windowStartMs = now;
|
||||
}
|
||||
long windowElapsed = now - windowStartMs;
|
||||
if (windowElapsed >= 1000) {
|
||||
lastSecondInFps = windowVideoPackets;
|
||||
windowVideoPackets = 0;
|
||||
windowVideoBytes = 0;
|
||||
windowStartMs = now;
|
||||
}
|
||||
windowVideoPackets++;
|
||||
windowVideoBytes += bytes;
|
||||
if (firstVideoPacketMs == 0) {
|
||||
firstVideoPacketMs = now;
|
||||
@@ -83,7 +95,7 @@ public final class StreamMetrics {
|
||||
public synchronized String format(String transport, int streamW, int streamH, int renderW, int renderH,
|
||||
boolean idle) {
|
||||
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);
|
||||
int localKbps = computeLocalKbps(now);
|
||||
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 AudioCapture audioCapture;
|
||||
private CastSession session;
|
||||
private CastSendPump sendPump;
|
||||
private NetworkFeedbackManager networkFeedback;
|
||||
private PowerManager.WakeLock wakeLock;
|
||||
private Thread ioThread;
|
||||
@@ -158,6 +159,8 @@ public class ScreenCastService extends Service {
|
||||
connectWithRetry(host, port, settings);
|
||||
updateStatus(getString(R.string.authenticating));
|
||||
session.clientHandshake(deviceName != null ? deviceName : "Sender", pin, settings);
|
||||
sendPump = new CastSendPump();
|
||||
sendPump.start(session, () -> stopping.set(true));
|
||||
networkFeedback = new NetworkFeedbackManager(session, true);
|
||||
casting.set(true);
|
||||
activeSettings = settings;
|
||||
@@ -175,7 +178,6 @@ public class ScreenCastService extends Service {
|
||||
}
|
||||
}
|
||||
}
|
||||
Thread.sleep(400);
|
||||
} catch (java.net.SocketException e) {
|
||||
if (!stopping.get()) {
|
||||
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 {
|
||||
@@ -237,8 +243,12 @@ public class ScreenCastService extends Service {
|
||||
mainHandler.post(() -> {
|
||||
try {
|
||||
startCaptureOnMainThread(resultCode, data, settings);
|
||||
} catch (IOException e) {
|
||||
error.set(e);
|
||||
} catch (Exception e) {
|
||||
if (e instanceof IOException) {
|
||||
error.set((IOException) e);
|
||||
} else {
|
||||
error.set(new IOException(e.getMessage() != null ? e.getMessage() : e.toString(), e));
|
||||
}
|
||||
} finally {
|
||||
latch.countDown();
|
||||
}
|
||||
@@ -339,8 +349,9 @@ public class ScreenCastService extends Service {
|
||||
|
||||
@Override
|
||||
public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
|
||||
sendSafe(CastProtocol.MSG_VIDEO_FRAME,
|
||||
() -> CastProtocol.videoFramePayload(ptsUs, keyFrame, frameData));
|
||||
if (sendPump != null) {
|
||||
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) {
|
||||
if (stopping.get()) {
|
||||
if (stopping.get() || sendPump == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
send(type, builder.build());
|
||||
sendPump.enqueue(type, builder.build());
|
||||
} catch (IOException e) {
|
||||
if (!stopping.get()) {
|
||||
Log.w(TAG, "Send failed: " + e.getMessage());
|
||||
Log.w(TAG, "Payload build failed: " + e.getMessage());
|
||||
stopping.set(true);
|
||||
}
|
||||
}
|
||||
@@ -484,6 +488,10 @@ public class ScreenCastService extends Service {
|
||||
projection.stop();
|
||||
projection = null;
|
||||
}
|
||||
if (sendPump != null) {
|
||||
sendPump.stop();
|
||||
sendPump = null;
|
||||
}
|
||||
if (session != null) {
|
||||
try {
|
||||
session.send(CastProtocol.MSG_GOODBYE, new byte[0]);
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.foxx.androidcast.sender;
|
||||
import android.media.MediaCodec;
|
||||
import android.media.MediaCodecInfo;
|
||||
import android.media.MediaFormat;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.Surface;
|
||||
@@ -13,7 +14,7 @@ import com.foxx.androidcast.CastSettings;
|
||||
import java.io.IOException;
|
||||
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 {
|
||||
private static final String TAG = "VideoEncoder";
|
||||
private static final String MIME = MediaFormat.MIMETYPE_VIDEO_AVC;
|
||||
@@ -37,22 +38,67 @@ public class VideoEncoder {
|
||||
this.height = height;
|
||||
this.callback = callback;
|
||||
|
||||
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);
|
||||
MediaFormat tuned = buildFormat(width, height, settings, true);
|
||||
MediaFormat basic = buildFormat(width, height, settings, false);
|
||||
|
||||
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();
|
||||
codec.start();
|
||||
running = true;
|
||||
drainThread = new Thread(this::drainLoop, "VideoEncoderDrain");
|
||||
drainThread.start();
|
||||
Log.i(TAG, "Encoder " + width + "x" + height + " @ " + settings.getVideoFps() + "fps "
|
||||
+ (settings.getVideoBitrate() / 1000) + "kbps");
|
||||
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() {
|
||||
if (codec == null) {
|
||||
return;
|
||||
@@ -93,7 +139,7 @@ public class VideoEncoder {
|
||||
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
||||
boolean configSent = false;
|
||||
while (running) {
|
||||
int index = codec.dequeueOutputBuffer(info, 10_000);
|
||||
int index = codec.dequeueOutputBuffer(info, 5_000);
|
||||
if (index == MediaCodec.INFO_TRY_AGAIN_LATER) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
<string name="continue_video_only">Continue (video only)</string>
|
||||
<string name="transport_tcp">TCP (reliable)</string>
|
||||
<string name="transport_udp">UDP (best effort)</string>
|
||||
<string name="quality_low">Low (~0.8 Mbps)</string>
|
||||
<string name="quality_medium">Medium (~2 Mbps)</string>
|
||||
<string name="quality_high">High (~4 Mbps)</string>
|
||||
<string name="quality_low">Low (~1.5 Mbps, 24 fps)</string>
|
||||
<string name="quality_medium">Medium (~5 Mbps, 30 fps) — recommended</string>
|
||||
<string name="quality_high">High (~10 Mbps, 30 fps)</string>
|
||||
<string name="resolution_full">Full display</string>
|
||||
<string name="resolution_75">75% 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_missing_params">Missing host, PIN, or settings</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="playback_opened">Fullscreen playback %1$dx%2$d</string>
|
||||
<string name="playback_rendering">Video playing</string>
|
||||
|
||||
Reference in New Issue
Block a user