diff --git a/app/src/main/java/com/foxx/androidcast/CastConfig.java b/app/src/main/java/com/foxx/androidcast/CastConfig.java index 020446d..9ce00a8 100644 --- a/app/src/main/java/com/foxx/androidcast/CastConfig.java +++ b/app/src/main/java/com/foxx/androidcast/CastConfig.java @@ -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; diff --git a/app/src/main/java/com/foxx/androidcast/CastSettings.java b/app/src/main/java/com/foxx/androidcast/CastSettings.java index 929117e..49924a7 100644 --- a/app/src/main/java/com/foxx/androidcast/CastSettings.java +++ b/app/src/main/java/com/foxx/androidcast/CastSettings.java @@ -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() { diff --git a/app/src/main/java/com/foxx/androidcast/SettingsUi.java b/app/src/main/java/com/foxx/androidcast/SettingsUi.java index 7d798ba..b00e0ad 100644 --- a/app/src/main/java/com/foxx/androidcast/SettingsUi.java +++ b/app/src/main/java/com/foxx/androidcast/SettingsUi.java @@ -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) { diff --git a/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java b/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java index b381a9d..bb3383a 100644 --- a/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java +++ b/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java @@ -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()); diff --git a/app/src/main/java/com/foxx/androidcast/network/CastSession.java b/app/src/main/java/com/foxx/androidcast/network/CastSession.java index 31c67e1..8440e90 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastSession.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastSession.java @@ -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(); } diff --git a/app/src/main/java/com/foxx/androidcast/network/CastTransport.java b/app/src/main/java/com/foxx/androidcast/network/CastTransport.java index 3ca205a..fc39519 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastTransport.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastTransport.java @@ -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(); } diff --git a/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java b/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java index c953e53..4a42757 100644 --- a/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java +++ b/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java @@ -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; diff --git a/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java b/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java index dd60460..3d04722 100644 --- a/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java +++ b/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java @@ -24,12 +24,19 @@ public class UdpCastTransport implements CastTransport { private final Map 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) { diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java index 1d0331d..acc643a 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java @@ -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(); diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java index 13b62d3..4009734 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java @@ -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)); + 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.listen(CastConfig.CAST_PORT); - postStatus("Listening (" + session.getTransport().getModeLabel() + ") port " - + CastConfig.CAST_PORT); + 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(); diff --git a/app/src/main/java/com/foxx/androidcast/receiver/StreamMetrics.java b/app/src/main/java/com/foxx/androidcast/receiver/StreamMetrics.java index 0ee8dd5..1abec58 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/StreamMetrics.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/StreamMetrics.java @@ -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; diff --git a/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java b/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java new file mode 100644 index 0000000..f049140 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java @@ -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 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); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java index 1d2754b..d296754 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java +++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java @@ -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]); diff --git a/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java b/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java index 3770074..fa1a774 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java +++ b/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java @@ -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; } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f460fb3..208f5e8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -22,9 +22,9 @@ Continue (video only) TCP (reliable) UDP (best effort) - Low (~0.8 Mbps) - Medium (~2 Mbps) - High (~4 Mbps) + Low (~1.5 Mbps, 24 fps) + Medium (~5 Mbps, 30 fps) — recommended + High (~10 Mbps, 30 fps) Full display 75% scale 50% scale @@ -56,6 +56,8 @@ Screen capture data missing (retry cast) Missing host, PIN, or settings No cast intent + Receiver not listening at %1$s:%2$d — on the tablet tap Start listening, then Refresh on this phone. + Could not connect to %1$s:%2$d Cast starting — see notification for status Fullscreen playback %1$dx%2$d Video playing