ENGINE = new AtomicReference<>();
+
+ private CronetEngineProvider() {}
+
+ public static CronetEngine getEngine(Context context) throws Exception {
+ CronetEngine existing = ENGINE.get();
+ if (existing != null) {
+ return existing;
+ }
+ synchronized (CronetEngineProvider.class) {
+ existing = ENGINE.get();
+ if (existing != null) {
+ return existing;
+ }
+ Context app = context.getApplicationContext();
+ Tasks.await(CronetProviderInstaller.installProvider(app), 30, TimeUnit.SECONDS);
+ ExperimentalCronetEngine.Builder builder =
+ new ExperimentalCronetEngine.Builder(app)
+ .enableQuic(true)
+ .enableHttp2(true)
+ .setStoragePath(app.getCacheDir().getAbsolutePath())
+ .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, 256 * 1024);
+ CronetEngine built = builder.build();
+ ENGINE.set(built);
+ return built;
+ }
+ }
+
+ public static void shutdown() {
+ CronetEngine e = ENGINE.getAndSet(null);
+ if (e != null) {
+ e.shutdown();
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/network/quic/QuicCronetCastTransport.java b/app/src/main/java/com/foxx/androidcast/network/quic/QuicCronetCastTransport.java
new file mode 100644
index 0000000..f706009
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/network/quic/QuicCronetCastTransport.java
@@ -0,0 +1,138 @@
+package com.foxx.androidcast.network.quic;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.foxx.androidcast.CastConfig;
+import com.foxx.androidcast.network.CastFramingContext;
+import com.foxx.androidcast.network.CastProtocol;
+import com.foxx.androidcast.network.CastTransport;
+import com.foxx.androidcast.network.TcpCastTransport;
+import com.foxx.androidcast.network.UdpCastTransport;
+
+import org.chromium.net.CronetEngine;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Experimental QUIC cast transport (Cronet stack).
+ *
+ * Cronet on Android is a QUIC/HTTP3 client only. This transport uses:
+ *
+ * - {@link CronetEngine} with QUIC enabled (shared via {@link CronetEngineProvider})
+ * - Framed cast messages on {@link CastConfig#QUIC_PORT} over UDP (same as legacy ACUD framing)
+ * - TCP fallback on the same port if UDP bind fails
+ *
+ * A future revision can move the data plane to Cronet {@code BidirectionalStream} once an HTTP/3
+ * terminator is available on the receiver.
+ */
+public final class QuicCronetCastTransport implements CastTransport {
+ private static final String TAG = "QuicCronetTransport";
+
+ private final Context appContext;
+ private final UdpCastTransport udp = new UdpCastTransport();
+ private final TcpCastTransport tcpFallback = new TcpCastTransport();
+ private final AtomicBoolean usingTcp = new AtomicBoolean(false);
+ private CronetEngine engine;
+
+ public QuicCronetCastTransport(Context context) {
+ this.appContext = context.getApplicationContext();
+ }
+
+ private CastTransport active() {
+ return usingTcp.get() ? tcpFallback : udp;
+ }
+
+ @Override
+ public void setFramingContext(CastFramingContext context) {
+ udp.setFramingContext(context);
+ tcpFallback.setFramingContext(context);
+ }
+
+ @Override
+ public void setInboundSessionGate(com.foxx.androidcast.network.CastSessionGate gate) {
+ udp.setInboundSessionGate(gate);
+ tcpFallback.setInboundSessionGate(gate);
+ }
+
+ private void ensureCronet() {
+ if (engine != null) {
+ return;
+ }
+ try {
+ engine = CronetEngineProvider.getEngine(appContext);
+ Log.i(TAG, "Cronet engine ready (QUIC/HTTP3 enabled for experimental transport)");
+ } catch (Exception e) {
+ Log.w(TAG, "Cronet engine unavailable: " + e.getMessage());
+ }
+ }
+
+ @Override
+ public void prepareListen(int port) throws IOException {
+ ensureCronet();
+ int quicPort = port > 0 ? port : CastConfig.QUIC_PORT;
+ try {
+ udp.prepareListen(quicPort);
+ usingTcp.set(false);
+ Log.i(TAG, "QUIC listen UDP :" + quicPort);
+ } catch (IOException e) {
+ Log.w(TAG, "QUIC UDP listen failed, TCP fallback :" + quicPort, e);
+ tcpFallback.prepareListen(quicPort);
+ usingTcp.set(true);
+ }
+ }
+
+ @Override
+ public void acceptClient() throws IOException {
+ active().acceptClient();
+ }
+
+ @Override
+ public void listen(int port) throws IOException {
+ prepareListen(port);
+ acceptClient();
+ }
+
+ @Override
+ public void connect(String host, int port) throws IOException {
+ ensureCronet();
+ int quicPort = port > 0 ? port : CastConfig.QUIC_PORT;
+ try {
+ udp.connect(host, quicPort);
+ usingTcp.set(false);
+ Log.i(TAG, "QUIC connect UDP " + host + ":" + quicPort);
+ } catch (IOException e) {
+ Log.w(TAG, "QUIC UDP connect failed, TCP fallback", e);
+ tcpFallback.connect(host, quicPort);
+ usingTcp.set(true);
+ }
+ }
+
+ @Override
+ public void send(byte type, byte[] payload) throws IOException {
+ active().send(type, payload);
+ }
+
+ @Override
+ public CastProtocol.Message receive(int timeoutMs) throws IOException {
+ return active().receive(timeoutMs);
+ }
+
+ @Override
+ public String getModeLabel() {
+ String base = usingTcp.get() ? "QUIC(exp/TCP)" : "QUIC(exp/UDP)";
+ return engine != null ? base + "+Cronet" : base;
+ }
+
+ @Override
+ public void releaseConnection() {
+ active().releaseConnection();
+ }
+
+ @Override
+ public void close() {
+ udp.close();
+ tcpFallback.close();
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionEngine.java b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionEngine.java
new file mode 100644
index 0000000..dffb504
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionEngine.java
@@ -0,0 +1,95 @@
+package com.foxx.androidcast.network.transport;
+
+/**
+ * Hook for future FEC / NACK over UDP. Current builds use {@link #noop()} only.
+ */
+public interface StreamProtectionEngine {
+ StreamProtectionMode mode();
+
+ /** Called before UDP payload is fragmented (outbound). */
+ byte[] onOutboundPayload(byte type, byte[] payload);
+
+ /** Called when a reassembled UDP message is ready (inbound). */
+ byte[] onInboundPayload(byte type, byte[] payload);
+
+ /** Future: process FEC repair block (stub). */
+ default void onFecBlockStub(byte[] block) {
+ }
+
+ /** Future: sender handles NACK for messageId (stub). */
+ default void onNackStub(int messageId) {
+ }
+
+ static StreamProtectionEngine noop() {
+ return new StreamProtectionEngine() {
+ @Override
+ public StreamProtectionMode mode() {
+ return StreamProtectionMode.NONE;
+ }
+
+ @Override
+ public byte[] onOutboundPayload(byte type, byte[] payload) {
+ return payload;
+ }
+
+ @Override
+ public byte[] onInboundPayload(byte type, byte[] payload) {
+ return payload;
+ }
+ };
+ }
+
+ /** Placeholder for upcoming FEC — counts invocations only. */
+ static StreamProtectionEngine fecStub(TransportLossStats stats) {
+ return new StreamProtectionEngine() {
+ @Override
+ public StreamProtectionMode mode() {
+ return StreamProtectionMode.FEC_STUB;
+ }
+
+ @Override
+ public byte[] onOutboundPayload(byte type, byte[] payload) {
+ if (stats != null) {
+ synchronized (stats) {
+ stats.fecPacketsStub++;
+ }
+ }
+ return payload;
+ }
+
+ @Override
+ public byte[] onInboundPayload(byte type, byte[] payload) {
+ return payload;
+ }
+ };
+ }
+
+ /** Placeholder for upcoming NACK — no retransmit yet. */
+ static StreamProtectionEngine nackStub(TransportLossStats stats) {
+ return new StreamProtectionEngine() {
+ @Override
+ public StreamProtectionMode mode() {
+ return StreamProtectionMode.NACK_STUB;
+ }
+
+ @Override
+ public byte[] onOutboundPayload(byte type, byte[] payload) {
+ return payload;
+ }
+
+ @Override
+ public byte[] onInboundPayload(byte type, byte[] payload) {
+ return payload;
+ }
+
+ @Override
+ public void onNackStub(int messageId) {
+ if (stats != null) {
+ synchronized (stats) {
+ stats.nackRequestsStub++;
+ }
+ }
+ }
+ };
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionMode.java b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionMode.java
new file mode 100644
index 0000000..142561c
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionMode.java
@@ -0,0 +1,26 @@
+package com.foxx.androidcast.network.transport;
+
+/** Planned LAN cast reliability features (stubs until implemented). */
+public enum StreamProtectionMode {
+ NONE,
+ /** Forward-error correction over UDP (not implemented). */
+ FEC_STUB,
+ /** Negative acknowledgement / retransmit requests (not implemented). */
+ NACK_STUB;
+
+ public boolean isStub() {
+ return this == FEC_STUB || this == NACK_STUB;
+ }
+
+ public String overlayLabel() {
+ switch (this) {
+ case FEC_STUB:
+ return "FEC (stub)";
+ case NACK_STUB:
+ return "NACK (stub)";
+ case NONE:
+ default:
+ return "none";
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStats.java b/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStats.java
new file mode 100644
index 0000000..ddfa402
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStats.java
@@ -0,0 +1,97 @@
+package com.foxx.androidcast.network.transport;
+
+/**
+ * Per-transport loss / damage counters (UDP datagrams, reassembly, app-layer drops).
+ * Thread-safe increments; copy for stats exchange.
+ */
+public final class TransportLossStats {
+ public long datagramsSent;
+ public long datagramsReceived;
+ public long datagramsInvalid;
+ public long fragmentsReceived;
+ public long fragmentsDuplicate;
+ public long reassemblyCompleted;
+ public long reassemblyIncomplete;
+ public long reassemblySuperseded;
+ public long wirePacketsDropped;
+ public long malformedPackets;
+
+ public int sendDroppedVideoP;
+ public int sendDroppedAudio;
+ public int sendQueueDepth;
+
+ public long recvVideoPackets;
+ public long recvAudioPackets;
+ public long recvVideoSkippedAwaitingKey;
+ public long recvVideoDecodeErrors;
+ public long recvAudioDecodeErrors;
+ public long recvAudioQueueDrops;
+ public long recvDamagedVideoFrames;
+
+ public StreamProtectionMode protectionMode = StreamProtectionMode.NONE;
+ public long fecPacketsStub;
+ public long nackRequestsStub;
+ public long nackRetransmitsStub;
+
+ public synchronized void addFrom(TransportLossStats other) {
+ if (other == null) {
+ return;
+ }
+ datagramsSent += other.datagramsSent;
+ datagramsReceived += other.datagramsReceived;
+ datagramsInvalid += other.datagramsInvalid;
+ fragmentsReceived += other.fragmentsReceived;
+ fragmentsDuplicate += other.fragmentsDuplicate;
+ reassemblyCompleted += other.reassemblyCompleted;
+ reassemblyIncomplete += other.reassemblyIncomplete;
+ reassemblySuperseded += other.reassemblySuperseded;
+ wirePacketsDropped += other.wirePacketsDropped;
+ malformedPackets += other.malformedPackets;
+ sendDroppedVideoP += other.sendDroppedVideoP;
+ sendDroppedAudio += other.sendDroppedAudio;
+ recvVideoPackets += other.recvVideoPackets;
+ recvAudioPackets += other.recvAudioPackets;
+ recvVideoSkippedAwaitingKey += other.recvVideoSkippedAwaitingKey;
+ recvVideoDecodeErrors += other.recvVideoDecodeErrors;
+ recvAudioDecodeErrors += other.recvAudioDecodeErrors;
+ recvAudioQueueDrops += other.recvAudioQueueDrops;
+ recvDamagedVideoFrames += other.recvDamagedVideoFrames;
+ fecPacketsStub += other.fecPacketsStub;
+ nackRequestsStub += other.nackRequestsStub;
+ nackRetransmitsStub += other.nackRetransmitsStub;
+ }
+
+ public synchronized TransportLossStats copy() {
+ TransportLossStats c = new TransportLossStats();
+ c.datagramsSent = datagramsSent;
+ c.datagramsReceived = datagramsReceived;
+ c.datagramsInvalid = datagramsInvalid;
+ c.fragmentsReceived = fragmentsReceived;
+ c.fragmentsDuplicate = fragmentsDuplicate;
+ c.reassemblyCompleted = reassemblyCompleted;
+ c.reassemblyIncomplete = reassemblyIncomplete;
+ c.reassemblySuperseded = reassemblySuperseded;
+ c.wirePacketsDropped = wirePacketsDropped;
+ c.malformedPackets = malformedPackets;
+ c.sendDroppedVideoP = sendDroppedVideoP;
+ c.sendDroppedAudio = sendDroppedAudio;
+ c.sendQueueDepth = sendQueueDepth;
+ c.recvVideoPackets = recvVideoPackets;
+ c.recvAudioPackets = recvAudioPackets;
+ c.recvVideoSkippedAwaitingKey = recvVideoSkippedAwaitingKey;
+ c.recvVideoDecodeErrors = recvVideoDecodeErrors;
+ c.recvAudioDecodeErrors = recvAudioDecodeErrors;
+ c.recvAudioQueueDrops = recvAudioQueueDrops;
+ c.recvDamagedVideoFrames = recvDamagedVideoFrames;
+ c.protectionMode = protectionMode;
+ c.fecPacketsStub = fecPacketsStub;
+ c.nackRequestsStub = nackRequestsStub;
+ c.nackRetransmitsStub = nackRetransmitsStub;
+ return c;
+ }
+
+ /** Estimated UDP loss: incomplete reassembly + invalid, not double-counting fragments. */
+ public synchronized long estimatedUdpLossEvents() {
+ return reassemblyIncomplete + reassemblySuperseded + datagramsInvalid + malformedPackets;
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStatsBridge.java b/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStatsBridge.java
new file mode 100644
index 0000000..ee90f8b
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStatsBridge.java
@@ -0,0 +1,42 @@
+package com.foxx.androidcast.network.transport;
+
+import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
+
+/** Copies {@link TransportLossStats} into {@link NetworkStatsSnapshot} for overlay / heartbeat. */
+public final class TransportLossStatsBridge {
+ private TransportLossStatsBridge() {}
+
+ public static void applyTo(NetworkStatsSnapshot s, TransportLossStats loss) {
+ if (s == null || loss == null) {
+ return;
+ }
+ synchronized (loss) {
+ s.udpDatagramsRx = loss.datagramsReceived;
+ s.udpDatagramsTx = loss.datagramsSent;
+ s.udpDatagramsInvalid = loss.datagramsInvalid;
+ s.udpFragmentsRx = loss.fragmentsReceived;
+ s.udpFragmentsDuplicate = loss.fragmentsDuplicate;
+ s.udpReassemblyOk = loss.reassemblyCompleted;
+ s.udpReassemblyLost = loss.reassemblyIncomplete + loss.reassemblySuperseded;
+ s.udpWireDropped = loss.wirePacketsDropped;
+ s.udpMalformed = loss.malformedPackets;
+ s.droppedPFrames = Math.max(s.droppedPFrames, loss.sendDroppedVideoP);
+ s.droppedAudioFrames = loss.sendDroppedAudio;
+ s.encodeQueueDepth = Math.max(s.encodeQueueDepth, loss.sendQueueDepth);
+ s.recvVideoPackets = loss.recvVideoPackets;
+ s.recvAudioPackets = loss.recvAudioPackets;
+ s.recvVideoSkippedKey = loss.recvVideoSkippedAwaitingKey;
+ s.recvVideoDecodeErrors = loss.recvVideoDecodeErrors;
+ s.recvAudioDecodeErrors = loss.recvAudioDecodeErrors;
+ s.recvAudioQueueDrops = loss.recvAudioQueueDrops;
+ s.recvDamagedVideoFrames = loss.recvDamagedVideoFrames;
+ s.protectionMode = loss.protectionMode.name();
+ s.fecStubPackets = loss.fecPacketsStub;
+ s.nackStubRequests = loss.nackRequestsStub;
+ long lossEvents = loss.estimatedUdpLossEvents();
+ if (loss.datagramsReceived > 0 && lossEvents > 0) {
+ s.lossRatio = Math.min(1f, lossEvents / (float) loss.datagramsReceived);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/TransportStatsProvider.java b/app/src/main/java/com/foxx/androidcast/network/transport/TransportStatsProvider.java
new file mode 100644
index 0000000..002e0d9
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/network/transport/TransportStatsProvider.java
@@ -0,0 +1,6 @@
+package com.foxx.androidcast.network.transport;
+
+/** Transports that expose {@link TransportLossStats}. */
+public interface TransportStatsProvider {
+ TransportLossStats getLossStats();
+}
diff --git a/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcCastTransport.java b/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcCastTransport.java
new file mode 100644
index 0000000..79cf591
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcCastTransport.java
@@ -0,0 +1,62 @@
+package com.foxx.androidcast.network.webrtc;
+
+import com.foxx.androidcast.network.CastProtocol;
+import com.foxx.androidcast.network.CastTransport;
+
+import java.io.IOException;
+
+/**
+ * Future WebRTC transport (libwebrtc or pion-backed). Not wired yet — use {@link #createStub()}.
+ */
+public interface WebRtcCastTransport extends CastTransport {
+
+ /** Signaling / ICE configuration for upcoming integration. */
+ interface SignalingConfig {
+ String getSignalingUrl();
+
+ String[] getIceServers();
+ }
+
+ /** Attach when integrating real PeerConnection / DataChannel. */
+ void attachPeer(WebRtcPeerAdapter peer);
+
+ /**
+ * Placeholder until libwebrtc or pion transport is connected.
+ */
+ static WebRtcCastTransport createStub() {
+ return new WebRtcCastTransportStub();
+ }
+
+ final class WebRtcCastTransportStub implements WebRtcCastTransport {
+ @Override
+ public void attachPeer(WebRtcPeerAdapter peer) {}
+
+ @Override
+ public void listen(int port) throws IOException {
+ throw new UnsupportedOperationException("WebRTC transport is not implemented yet");
+ }
+
+ @Override
+ public void connect(String host, int port) throws IOException {
+ throw new UnsupportedOperationException("WebRTC transport is not implemented yet");
+ }
+
+ @Override
+ public void send(byte type, byte[] payload) throws IOException {
+ throw new UnsupportedOperationException("WebRTC transport is not implemented yet");
+ }
+
+ @Override
+ public CastProtocol.Message receive(int timeoutMs) throws IOException {
+ throw new UnsupportedOperationException("WebRTC transport is not implemented yet");
+ }
+
+ @Override
+ public String getModeLabel() {
+ return "WebRTC (stub)";
+ }
+
+ @Override
+ public void close() {}
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcPeerAdapter.java b/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcPeerAdapter.java
new file mode 100644
index 0000000..fdacb5e
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcPeerAdapter.java
@@ -0,0 +1,37 @@
+package com.foxx.androidcast.network.webrtc;
+
+/**
+ * Abstraction over future {@code org.webrtc.PeerConnection} or pion peer APIs.
+ * Implementations will map cast frames to SCTP DataChannel or encoded tracks.
+ */
+public interface WebRtcPeerAdapter {
+
+ interface Observer {
+ void onIceCandidate(String sdpMid, int sdpMLineIndex, String candidate);
+
+ void onConnectionStateChanged(WebRtcConnectionState state);
+
+ void onDataChannelMessage(byte[] payload);
+
+ void onEncodedVideoFrame(long ptsUs, boolean keyFrame, byte[] data);
+ }
+
+ enum WebRtcConnectionState {
+ NEW, CONNECTING, CONNECTED, DISCONNECTED, FAILED, CLOSED
+ }
+
+ void setObserver(Observer observer);
+
+ void createOffer();
+
+ void createAnswer(String remoteDescription);
+
+ void setRemoteDescription(String sdp, boolean isOffer);
+
+ void addIceCandidate(String sdpMid, int sdpMLineIndex, String candidate);
+
+ /** Send opaque cast protocol bytes on a labeled DataChannel. */
+ void sendDataChannel(byte[] payload);
+
+ void close();
+}
diff --git a/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcTransportFactory.java b/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcTransportFactory.java
new file mode 100644
index 0000000..8aec38f
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcTransportFactory.java
@@ -0,0 +1,19 @@
+package com.foxx.androidcast.network.webrtc;
+
+import com.foxx.androidcast.CastConfig;
+import com.foxx.androidcast.network.CastTransport;
+
+/**
+ * Factory for WebRTC-backed cast transport (stub today; swap implementation for libwebrtc / pion).
+ */
+public final class WebRtcTransportFactory {
+ private WebRtcTransportFactory() {}
+
+ public static boolean isTransportId(String transport) {
+ return CastConfig.TRANSPORT_WEBRTC.equals(transport);
+ }
+
+ public static CastTransport create() {
+ return WebRtcCastTransport.createStub();
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java b/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java
index 9abe17d..79fb2f5 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java
@@ -7,6 +7,7 @@ import android.media.AudioManager;
import android.media.AudioTrack;
import android.media.MediaCodec;
import android.media.MediaFormat;
+import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
import android.util.Log;
@@ -36,6 +37,7 @@ public class AudioDecoder {
public long pcmSilentBlocks;
public long pcmAudibleBlocks;
public long decodeErrors;
+ public long queueDrops;
public float lastPeak;
public float lastRms;
}
@@ -43,7 +45,8 @@ public class AudioDecoder {
private final Context appContext;
private final HandlerThread workerThread = new HandlerThread("AudioDecoderCtrl");
private Handler worker;
- private final LinkedBlockingQueue inputQueue = new LinkedBlockingQueue<>(128);
+ private final LinkedBlockingQueue inputQueue = new LinkedBlockingQueue<>(256);
+ private long playbackEpochUs = -1;
private final Stats stats = new Stats();
private MediaCodec codec;
@@ -130,6 +133,7 @@ public class AudioDecoder {
private void startDrainThread() {
drainThread = new Thread(this::drainLoop, "AudioDecoderDrain");
+ drainThread.setPriority(Thread.MAX_PRIORITY - 2);
drainThread.start();
}
@@ -138,7 +142,7 @@ public class AudioDecoder {
? AudioFormat.CHANNEL_OUT_STEREO
: AudioFormat.CHANNEL_OUT_MONO;
int minBuf = AudioTrack.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT);
- int bufSize = Math.max(minBuf * 8, 65536);
+ int bufSize = Math.max(minBuf * 16, 131072);
track = new AudioTrack.Builder()
.setAudioAttributes(new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
@@ -164,8 +168,11 @@ public class AudioDecoder {
}
stats.framesQueued++;
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
+ stats.queueDrops++;
inputQueue.poll();
- inputQueue.offer(new PendingFrame(ptsUs, data.clone()));
+ if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
+ stats.queueDrops++;
+ }
}
}
@@ -241,7 +248,8 @@ public class AudioDecoder {
} else {
stats.pcmAudibleBlocks++;
}
- int written = writePcm(pcm);
+ pacePlayback(info.presentationTimeUs);
+ int written = writePcmBlocking(pcm);
if (written > 0) {
stats.pcmBytesWritten += written;
if (renderCallback != null) {
@@ -253,16 +261,42 @@ public class AudioDecoder {
}
}
- private int writePcm(byte[] pcm) {
+ private void pacePlayback(long ptsUs) {
+ long nowUs = System.nanoTime() / 1000L;
+ if (playbackEpochUs < 0) {
+ playbackEpochUs = nowUs - ptsUs;
+ return;
+ }
+ long dueUs = playbackEpochUs + ptsUs;
+ long aheadUs = dueUs - nowUs;
+ if (aheadUs > 120_000L) {
+ try {
+ Thread.sleep(aheadUs / 1000L, (int) ((aheadUs % 1000L) * 1000L));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ private int writePcmBlocking(byte[] pcm) {
int total = 0;
int offset = 0;
- while (offset < pcm.length) {
+ int idle = 0;
+ while (offset < pcm.length && idle < 80) {
int w = track.write(pcm, offset, pcm.length - offset);
- if (w <= 0) {
+ if (w > 0) {
+ total += w;
+ offset += w;
+ idle = 0;
+ continue;
+ }
+ idle++;
+ try {
+ Thread.sleep(2);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
break;
}
- total += w;
- offset += w;
}
return total;
}
@@ -275,7 +309,11 @@ public class AudioDecoder {
}
}
- public void release() {
+ /** Stops playback immediately and drops queued AAC (sender ended cast). */
+ public void flushAndStop() {
+ draining = false;
+ configured = false;
+ inputQueue.clear();
final Object lock = new Object();
worker.post(() -> {
releaseOnWorker();
@@ -285,21 +323,26 @@ public class AudioDecoder {
});
synchronized (lock) {
try {
- lock.wait(2000);
+ lock.wait(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
+ public void release() {
+ flushAndStop();
+ }
+
private void releaseOnWorker() {
draining = false;
configured = false;
+ playbackEpochUs = -1;
inputQueue.clear();
if (drainThread != null) {
drainThread.interrupt();
try {
- drainThread.join(1500);
+ drainThread.join(400);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
@@ -315,12 +358,26 @@ public class AudioDecoder {
}
if (track != null) {
try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ track.pause();
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ track.flush();
+ }
track.stop();
} catch (Exception ignored) {
}
track.release();
track = null;
}
+ abandonAudioFocus();
+ }
+
+ private void abandonAudioFocus() {
+ AudioManager am = (AudioManager) appContext.getSystemService(Context.AUDIO_SERVICE);
+ if (am != null) {
+ am.abandonAudioFocus(null);
+ }
}
public void releaseAndQuit() {
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 6b93eea..5fd1ac7 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
@@ -22,7 +22,11 @@ import com.foxx.androidcast.R;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.diagnostics.CastDiagnosticsFormatter;
import com.foxx.androidcast.discovery.DiscoveryManager;
+import com.foxx.androidcast.network.transport.TransportLossStats;
+import com.foxx.androidcast.network.transport.TransportLossStatsBridge;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
+import com.foxx.androidcast.stats.SessionStatsRecorder;
+import com.foxx.androidcast.stats.SessionStatsStore;
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
import java.util.Arrays;
@@ -80,7 +84,10 @@ public class ReceiverCastService extends Service {
private CastSettings remoteCastSettings;
private NetworkStatsSnapshot lastLocalStats;
private boolean streamIdle;
+ private volatile boolean castEnded;
private long lastVideoFrameMs;
+ private final TransportLossStats transportLoss = new TransportLossStats();
+ private SessionStatsRecorder sessionStatsRecorder;
private final Runnable idleWatchdog = new Runnable() {
@Override
@@ -136,6 +143,7 @@ public class ReceiverCastService extends Service {
public void onCreate() {
super.onCreate();
CastNotifications.createChannels(this);
+ SessionStatsStore.pruneOnAppStart(this);
mainHandler.post(idleWatchdog);
}
@@ -175,7 +183,11 @@ public class ReceiverCastService extends Service {
private void restartListeningLocked(String pin) {
stopSessionOnly();
+ castEnded = false;
resetStreamState();
+ pendingWidth = 0;
+ pendingHeight = 0;
+ playbackLaunched = false;
phase = Phase.LISTENING;
acquireWakeLock();
startForeground(CastNotifications.ID_RECEIVER,
@@ -220,10 +232,10 @@ public class ReceiverCastService extends Service {
@Override
public void onVideoFrame(long ptsUs, boolean keyFrame, byte[] data) {
- if (reconfiguring) {
- return;
- }
if (awaitingKeyframe && !keyFrame) {
+ synchronized (transportLoss) {
+ transportLoss.recvVideoSkippedAwaitingKey++;
+ }
return;
}
if (keyFrame) {
@@ -237,7 +249,10 @@ public class ReceiverCastService extends Service {
onStreamResumedAfterIdle();
}
streamMetrics.onVideoPacket(keyFrame, frameBytes);
- if (videoDecoder != null && !reconfiguring) {
+ synchronized (transportLoss) {
+ transportLoss.recvVideoPackets++;
+ }
+ if (videoDecoder != null) {
videoDecoder.queueFrame(ptsUs, keyFrame, data);
}
});
@@ -271,6 +286,9 @@ public class ReceiverCastService extends Service {
@Override
public void onAudioFrame(long ptsUs, byte[] data) {
+ if (castEnded) {
+ return;
+ }
final int frameBytes = data != null ? data.length : 0;
final boolean accept;
synchronized (ReceiverCastService.this) {
@@ -279,7 +297,17 @@ public class ReceiverCastService extends Service {
if (accept && audioDecoder != null) {
audioDecoder.queueFrame(ptsUs, data);
}
- mainHandler.post(() -> streamMetrics.onAudioPacket(frameBytes));
+ mainHandler.post(() -> {
+ streamMetrics.onAudioPacket(frameBytes);
+ synchronized (transportLoss) {
+ transportLoss.recvAudioPackets++;
+ }
+ });
+ }
+
+ @Override
+ public void onCastEnded() {
+ mainHandler.postAtFrontOfQueue(ReceiverCastService.this::stopAudioPlaybackImmediate);
}
@Override
@@ -304,6 +332,13 @@ public class ReceiverCastService extends Service {
private void onSenderConnected(String senderName, CastSettings remoteSettings, String videoMime) {
phase = Phase.CONNECTED;
+ castEnded = false;
+ if (sessionStatsRecorder == null) {
+ sessionStatsRecorder = new SessionStatsRecorder("receiver", settings.getTransport());
+ sessionStatsRecorder.setClientCount(1);
+ }
+ sessionStatsRecorder.mergeSettings(remoteSettings);
+ sessionStatsRecorder.setCodecs(videoMime, "AAC");
negotiatedVideoMime = videoMime;
remoteCastSettings = remoteSettings;
streamIdle = false;
@@ -332,25 +367,32 @@ public class ReceiverCastService extends Service {
}, "AudioConsent").start();
}
+ private void stopAudioPlaybackImmediate() {
+ castEnded = true;
+ if (audioDecoder != null) {
+ audioDecoder.flushAndStop();
+ audioDecoder = new AudioDecoder(this);
+ audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered(
+ pcmBytes, levels.peak, levels.rms, levels.silent));
+ }
+ Log.i(TAG, "Audio playback stopped");
+ }
+
private void onSenderDisconnected() {
+ stopAudioPlaybackImmediate();
phase = Phase.LISTENING;
audioAccepted = null;
remoteCastSettings = null;
streamIdle = false;
lastVideoFrameMs = 0;
negotiatedVideoMime = null;
- enterAwaitingMode(R.string.playback_stream_ended, true);
- updateStatus(getString(R.string.waiting_sender));
- closePlaybackUi();
- openReceiverSetupUi();
- Log.i(TAG, "Sender disconnected");
- }
-
- private void openReceiverSetupUi() {
- Intent intent = new Intent(this, ReceiverActivity.class);
- intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP
- | Intent.FLAG_ACTIVITY_SINGLE_TOP);
- startActivity(intent);
+ pendingWidth = 0;
+ pendingHeight = 0;
+ hasPendingConfig = false;
+ resetDecoderState();
+ enterAwaitingMode(R.string.playback_listening, true);
+ updateStatus(getString(R.string.receiver_ready, activePin, settings.getTransport().toUpperCase()));
+ Log.i(TAG, "Sender disconnected — listening again");
}
private void checkStreamIdle() {
@@ -360,6 +402,9 @@ public class ReceiverCastService extends Service {
long idleMs = System.currentTimeMillis() - lastVideoFrameMs;
if (idleMs >= CastConfig.STREAM_IDLE_TIMEOUT_MS) {
Log.i(TAG, "Stream idle " + idleMs + "ms — awaiting");
+ if (sessionStatsRecorder != null) {
+ sessionStatsRecorder.noteIdleTimeout();
+ }
enterAwaitingMode(R.string.playback_awaiting_stream, false);
}
}
@@ -459,6 +504,9 @@ public class ReceiverCastService extends Service {
decoderActive = false;
notifyPlaybackDimensions(pendingWidth, pendingHeight);
scheduleDecoderConfigureRetries();
+ if (sessionStatsRecorder != null) {
+ sessionStatsRecorder.setResolution(pendingWidth, pendingHeight);
+ }
Log.i(TAG, "Stream config " + pendingWidth + "x" + pendingHeight);
}
@@ -504,9 +552,11 @@ public class ReceiverCastService extends Service {
return;
}
discovery = new DiscoveryManager(this);
- discovery.startAnnouncing(android.os.Build.MODEL, com.foxx.androidcast.CastConfig.CAST_PORT,
+ com.foxx.androidcast.network.CastTransportFactory.init(this);
+ int announcePort = com.foxx.androidcast.CastConfig.portForTransport(settings.getTransport());
+ discovery.startAnnouncing(android.os.Build.MODEL, announcePort,
settings.getTransport(), true);
- Log.i(TAG, "Discovery announcing on port " + com.foxx.androidcast.CastConfig.CAST_PORT);
+ Log.i(TAG, "Discovery announcing on port " + announcePort);
}
private void stopSessionOnly() {
@@ -545,6 +595,7 @@ public class ReceiverCastService extends Service {
if (!hasPendingConfig || attachedSurface == null || !attachedSurface.isValid()) {
Log.i(TAG, "Decoder waiting: config=" + hasPendingConfig
+ " surface=" + (attachedSurface != null && attachedSurface.isValid()));
+ reconfiguring = false;
return;
}
if (decoderActive && decoderWidth == pendingWidth && decoderHeight == pendingHeight) {
@@ -569,16 +620,24 @@ public class ReceiverCastService extends Service {
public void onFrameRendered() {
ReceiverCastService.this.onFrameRendered();
}
+
+ @Override
+ public void onOutputSizeChanged(int width, int height) {
+ ReceiverCastService.this.onDecoderOutputSize(width, height);
+ }
});
decoderActive = true;
reconfiguring = false;
decoderWidth = pendingWidth;
decoderHeight = pendingHeight;
- updateStatus(getString(R.string.playing_resolution, pendingWidth, pendingHeight));
+ updateStatus(getString(R.string.playback_awaiting_stream));
broadcastStreamStarted(pendingWidth, pendingHeight);
Log.i(TAG, "Decoder started " + pendingWidth + "x" + pendingHeight);
} catch (Exception e) {
decoderActive = false;
+ synchronized (transportLoss) {
+ transportLoss.recvVideoDecodeErrors++;
+ }
Log.e(TAG, "Video configure failed", e);
updateStatus("Video: " + e.getMessage());
}
@@ -589,6 +648,9 @@ public class ReceiverCastService extends Service {
streamMetrics.onRenderedFrame();
Log.i(TAG, "First frame on screen");
streamIdle = false;
+ if (pendingWidth > 0 && pendingHeight > 0) {
+ updateStatus(getString(R.string.playing_resolution, pendingWidth, pendingHeight));
+ }
broadcastVideoRendering();
});
}
@@ -597,8 +659,27 @@ public class ReceiverCastService extends Service {
mainHandler.post(() -> streamMetrics.onRenderedFrame());
}
+ private void onDecoderOutputSize(int width, int height) {
+ mainHandler.post(() -> {
+ if (width <= 0 || height <= 0) {
+ return;
+ }
+ pendingWidth = width;
+ pendingHeight = height;
+ decoderWidth = width;
+ decoderHeight = height;
+ notifyPlaybackDimensions(width, height);
+ Log.i(TAG, "Decoder output size " + width + "x" + height);
+ });
+ }
+
private void enrichReceiverStats(NetworkStatsSnapshot s, long nowMs) {
s.encodeFps = streamMetrics.getDecodeFps();
+ TransportLossStats snap = buildReceiverLossSnapshot();
+ TransportLossStatsBridge.applyTo(s, snap);
+ if (sessionStatsRecorder != null) {
+ sessionStatsRecorder.sampleNetwork(s, streamMetrics.getLastPeer());
+ }
NetworkStatsSnapshot peer = streamMetrics.getLastPeer();
CastSettings tune = remoteCastSettings != null ? remoteCastSettings : settings;
com.foxx.androidcast.CastTuningEngine.EffectiveParams eff =
@@ -634,6 +715,34 @@ public class ReceiverCastService extends Service {
PlaybackViewState.getZoomPercent());
}
+ private TransportLossStats buildReceiverLossSnapshot() {
+ TransportLossStats snap = session != null
+ ? session.getTransportLossStats() : new TransportLossStats();
+ synchronized (transportLoss) {
+ snap.recvVideoSkippedAwaitingKey = transportLoss.recvVideoSkippedAwaitingKey;
+ snap.recvVideoPackets = transportLoss.recvVideoPackets;
+ snap.recvAudioPackets = transportLoss.recvAudioPackets;
+ snap.recvVideoDecodeErrors = transportLoss.recvVideoDecodeErrors;
+ }
+ if (audioDecoder != null) {
+ AudioDecoder.Stats ast = audioDecoder.getStats();
+ snap.recvAudioDecodeErrors = ast.decodeErrors;
+ snap.recvAudioQueueDrops = ast.queueDrops;
+ }
+ return snap;
+ }
+
+ private void finishSessionStatsAsync() {
+ final SessionStatsRecorder recorder = sessionStatsRecorder;
+ sessionStatsRecorder = null;
+ if (recorder == null) {
+ return;
+ }
+ recorder.setResolution(pendingWidth, pendingHeight);
+ recorder.mergeLoss(buildReceiverLossSnapshot());
+ new Thread(() -> recorder.finish(getApplicationContext()), "SessionStatsSave").start();
+ }
+
private void attachSurface(Surface surface) {
if (surface == null || !surface.isValid()) {
Log.w(TAG, "attachSurface: invalid surface");
@@ -732,6 +841,7 @@ public class ReceiverCastService extends Service {
}
private void releaseAll() {
+ finishSessionStatsAsync();
phase = Phase.IDLE;
resetStreamState();
playbackLaunched = false;
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java
index ffbc216..ab92e8b 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java
@@ -9,8 +9,9 @@ import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.RemoteException;
-import android.view.SurfaceHolder;
-import android.view.SurfaceView;
+import android.graphics.SurfaceTexture;
+import android.view.Surface;
+import android.view.TextureView;
import android.view.View;
import android.view.WindowManager;
import android.animation.AnimatorSet;
@@ -27,6 +28,7 @@ import com.foxx.androidcast.ICastReceiverService;
import com.foxx.androidcast.ICastStatusCallback;
import com.foxx.androidcast.R;
import com.foxx.androidcast.ui.AspectRatioFrameLayout;
+import com.foxx.androidcast.ui.PlaybackTextureTransform;
import com.foxx.androidcast.ui.ZoomPanContainer;
import androidx.core.text.HtmlCompat;
@@ -35,7 +37,7 @@ import androidx.core.text.HtmlCompat;
* Fullscreen display: dancing-robot overlay while listening / waiting;
* video surface shown only after the first decoded frame.
*/
-public class ReceiverPlaybackActivity extends AppCompatActivity implements SurfaceHolder.Callback {
+public class ReceiverPlaybackActivity extends AppCompatActivity {
public static final String EXTRA_WIDTH = "video_width";
public static final String EXTRA_HEIGHT = "video_height";
public static final String EXTRA_FINISH = "finish_playback";
@@ -43,13 +45,13 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
public static final String EXTRA_AWAITING_MESSAGE = "awaiting_message";
private AspectRatioFrameLayout aspectContainer;
- private SurfaceView surfaceView;
+ private TextureView textureView;
private View awaitingOverlay;
private TextView diagnosticsText;
private ImageView robotView;
private TextView awaitingMessage;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
- private SurfaceHolder surfaceHolder;
+ private SurfaceTexture surfaceTexture;
private int videoWidth;
private int videoHeight;
private boolean streamRendering;
@@ -78,6 +80,11 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
runOnUiThread(() -> {
streamRendering = true;
hideAwaitingOverlay();
+ ZoomPanContainer zoom = findViewById(R.id.zoom_container);
+ if (zoom != null) {
+ zoom.resetTransform();
+ }
+ applyTextureFitCenter();
updateDiagnosticsVisibility();
});
}
@@ -138,15 +145,13 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
ZoomPanContainer zoomContainer = findViewById(R.id.zoom_container);
aspectContainer = findViewById(R.id.aspect_container);
- surfaceView = findViewById(R.id.surface_playback);
+ textureView = findViewById(R.id.surface_playback);
zoomContainer.setOnZoomChangedListener(PlaybackViewState::setZoomPercent);
awaitingOverlay = findViewById(R.id.overlay_awaiting);
diagnosticsText = findViewById(R.id.text_diagnostics);
robotView = findViewById(R.id.image_robot);
awaitingMessage = findViewById(R.id.text_awaiting_message);
- surfaceHolder = surfaceView.getHolder();
- surfaceHolder.addCallback(this);
- surfaceView.setZOrderMediaOverlay(false);
+ textureView.setSurfaceTextureListener(surfaceListener);
applyAspectRatio();
if (getIntent().getBooleanExtra(EXTRA_SHOW_AWAITING, true)) {
@@ -319,12 +324,24 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
videoWidth = width;
videoHeight = height;
applyAspectRatio();
- if (surfaceHolder != null && surfaceHolder.getSurface() != null && surfaceHolder.getSurface().isValid()) {
- surfaceHolder.setFixedSize(videoWidth, videoHeight);
+ applyTextureFitCenter();
+ if (surfaceTexture != null) {
+ surfaceTexture.setDefaultBufferSize(videoWidth, videoHeight);
attachSurfaceIfReady();
}
}
+ private void applyTextureFitCenter() {
+ if (textureView == null || videoWidth <= 0 || videoHeight <= 0) {
+ return;
+ }
+ if (textureView.getWidth() == 0 || textureView.getHeight() == 0) {
+ textureView.post(this::applyTextureFitCenter);
+ return;
+ }
+ PlaybackTextureTransform.applyFitCenter(textureView, videoWidth, videoHeight);
+ }
+
private void applyFullscreen() {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
getWindow().getDecorView().setSystemUiVisibility(
@@ -340,42 +357,49 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
}
private void applyAspectRatio() {
- if (videoWidth > 0 && videoHeight > 0) {
- aspectContainer.setAspectRatio(videoWidth, videoHeight);
- }
+ // Full-screen TextureView; letterboxing via transform only (avoids double-scaling).
+ aspectContainer.setAspectRatio(0, 0);
}
- @Override
- public void surfaceCreated(SurfaceHolder holder) {
- if (videoWidth > 0 && videoHeight > 0) {
- holder.setFixedSize(videoWidth, videoHeight);
- }
- surfaceSentToService = false;
- attachSurfaceIfReady();
- }
+ private final TextureView.SurfaceTextureListener surfaceListener =
+ new TextureView.SurfaceTextureListener() {
+ @Override
+ public void onSurfaceTextureAvailable(SurfaceTexture texture, int width, int height) {
+ surfaceTexture = texture;
+ if (videoWidth > 0 && videoHeight > 0) {
+ texture.setDefaultBufferSize(videoWidth, videoHeight);
+ }
+ surfaceSentToService = false;
+ attachSurfaceIfReady();
+ }
- @Override
- public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
- if (videoWidth > 0 && videoHeight > 0) {
- holder.setFixedSize(videoWidth, videoHeight);
- }
- }
+ @Override
+ public void onSurfaceTextureSizeChanged(SurfaceTexture texture, int width, int height) {
+ if (videoWidth > 0 && videoHeight > 0) {
+ texture.setDefaultBufferSize(videoWidth, videoHeight);
+ }
+ applyTextureFitCenter();
+ }
- @Override
- public void surfaceDestroyed(SurfaceHolder holder) {
- surfaceSentToService = false;
- detachSurface();
- }
+ @Override
+ public boolean onSurfaceTextureDestroyed(SurfaceTexture texture) {
+ surfaceSentToService = false;
+ detachSurface();
+ surfaceTexture = null;
+ return true;
+ }
+
+ @Override
+ public void onSurfaceTextureUpdated(SurfaceTexture texture) {
+ }
+ };
private void attachSurfaceIfReady() {
- if (surfaceSentToService || !bound || receiverService == null || surfaceHolder.getSurface() == null) {
- return;
- }
- if (!surfaceHolder.getSurface().isValid()) {
+ if (surfaceSentToService || !bound || receiverService == null || surfaceTexture == null) {
return;
}
try {
- receiverService.attachVideoSurface(surfaceHolder.getSurface());
+ receiverService.attachVideoSurface(new Surface(surfaceTexture));
surfaceSentToService = true;
} catch (RemoteException ignored) {
}
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 837a313..40acb61 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java
@@ -11,8 +11,10 @@ import com.foxx.androidcast.network.CastCodecFlags;
import com.foxx.androidcast.network.CastFramingContext;
import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.CastSession;
+import com.foxx.androidcast.network.CastSessionGate;
import com.foxx.androidcast.network.CastTransport;
import com.foxx.androidcast.network.CastTransportFactory;
+import com.foxx.androidcast.network.transport.TransportLossStats;
import com.foxx.androidcast.network.control.NetworkFeedbackManager;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
@@ -41,6 +43,9 @@ public class ReceiverSession {
void onDisconnected();
+ /** Sender sent {@link CastProtocol#MSG_GOODBYE} — stop audio/video playback immediately. */
+ void onCastEnded();
+
/** TCP/UDP listen socket is bound; safe to advertise on the LAN. */
void onListening();
@@ -58,6 +63,7 @@ public class ReceiverSession {
private CastSession session;
private CastTransport listenTransport;
+ private final CastSessionGate inboundSessionGate = new CastSessionGate();
private NetworkFeedbackManager networkFeedback;
private PassThroughNetworkControlPlane.StatsEnricher statsEnricher;
@@ -94,6 +100,14 @@ public class ReceiverSession {
listenTransport.close();
listenTransport = null;
}
+ inboundSessionGate.reset();
+ }
+
+ public TransportLossStats getTransportLossStats() {
+ if (listenTransport != null) {
+ return listenTransport.getLossStats();
+ }
+ return new TransportLossStats();
}
public void sendAudioConsent(boolean accept) {
@@ -111,17 +125,21 @@ public class ReceiverSession {
while (running.get()) {
try {
if (listenTransport == null) {
+ com.foxx.androidcast.network.CastTransportFactory.init(appContext);
listenTransport = CastTransportFactory.create(localSettings);
+ inboundSessionGate.reset();
+ listenTransport.setInboundSessionGate(inboundSessionGate);
listenTransport.setFramingContext(new CastFramingContext(
0,
DeviceInfo.getPlaceholderId(appContext),
CastCodecFlags.localDecoderVideoMask(),
CastCodecFlags.localAudioMask()));
session = new CastSession(listenTransport);
- session.prepareListen(CastConfig.CAST_PORT);
+ int listenPort = CastConfig.portForTransport(localSettings.getTransport());
+ session.prepareListen(listenPort);
listener.onListening();
postStatus("Listening (" + listenTransport.getModeLabel() + ") port "
- + CastConfig.CAST_PORT);
+ + listenPort);
} else if (session == null) {
session = new CastSession(listenTransport);
}
@@ -158,7 +176,13 @@ public class ReceiverSession {
} finally {
if (session != null) {
session.releaseConnection();
+ session = null;
}
+ if (listenTransport != null) {
+ listenTransport.close();
+ listenTransport = null;
+ }
+ inboundSessionGate.reset();
networkFeedback = null;
listener.onDisconnected();
}
@@ -214,6 +238,7 @@ public class ReceiverSession {
break;
case CastProtocol.MSG_GOODBYE:
postStatus("Sender stopped casting");
+ listener.onCastEnded();
return;
default:
break;
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java b/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java
index 857f825..dd11685 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java
@@ -22,6 +22,9 @@ public class VideoDecoder {
/** Called for each frame after the first. */
default void onFrameRendered() {}
+
+ /** Decoder output size (may differ from stream config crop). */
+ default void onOutputSizeChanged(int width, int height) {}
}
private final Object lock = new Object();
@@ -66,7 +69,7 @@ public class VideoDecoder {
if (released || data == null || data.length == 0) {
return;
}
- byte[] annexB = CodecNegotiator.isH264(mime) ? H264Bitstream.toAnnexB(data) : data;
+ byte[] annexB = needsAnnexB(mime) ? H264Bitstream.toAnnexB(data) : data;
if (!configured || codec == null) {
pending.add(new PendingFrame(ptsUs, keyFrame, annexB));
return;
@@ -143,7 +146,9 @@ public class VideoDecoder {
break;
}
if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
- Log.i(TAG, "Output format: " + c.getOutputFormat());
+ MediaFormat fmt = c.getOutputFormat();
+ Log.i(TAG, "Output format: " + fmt);
+ notifyOutputSizeLocked(fmt);
continue;
}
if (outIndex < 0) {
@@ -161,6 +166,23 @@ public class VideoDecoder {
}
}
+ private void notifyOutputSizeLocked(MediaFormat format) {
+ if (listener == null || format == null) {
+ return;
+ }
+ int w = format.getInteger(MediaFormat.KEY_WIDTH);
+ int h = format.getInteger(MediaFormat.KEY_HEIGHT);
+ if (format.containsKey("crop-right") && format.containsKey("crop-left")) {
+ w = format.getInteger("crop-right") + 1 - format.getInteger("crop-left");
+ }
+ if (format.containsKey("crop-bottom") && format.containsKey("crop-top")) {
+ h = format.getInteger("crop-bottom") + 1 - format.getInteger("crop-top");
+ }
+ if (w > 0 && h > 0) {
+ listener.onOutputSizeChanged(w, h);
+ }
+ }
+
private void notifyFirstFrameLocked() {
if (listener == null) {
return;
@@ -175,6 +197,10 @@ public class VideoDecoder {
}
}
+ private static boolean needsAnnexB(String mime) {
+ return CodecNegotiator.isH264(mime) || CodecNegotiator.isHevc(mime);
+ }
+
private static final class PendingFrame {
final long ptsUs;
final boolean keyFrame;
diff --git a/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java b/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java
index 6c07075..9cabc34 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java
@@ -50,6 +50,10 @@ public class AudioCapture {
Log.w(TAG, "Audio capture requires API 29+");
return;
}
+ if (projection == null) {
+ startMicOnly(callback);
+ return;
+ }
if (!startPlayback(projection)) {
Log.e(TAG, "Playback capture unavailable");
return;
@@ -72,6 +76,59 @@ public class AudioCapture {
thread.start();
}
+ private void startMicOnly(Callback callback) {
+ micRecord = canUseMic() ? buildMicRecord() : null;
+ if (micRecord == null || micRecord.getState() != AudioRecord.STATE_INITIALIZED) {
+ micRecord = null;
+ Log.w(TAG, "Mic-only capture unavailable");
+ return;
+ }
+ running = true;
+ micRecord.startRecording();
+ captureMode = "mic";
+ if (callback != null) {
+ callback.onCaptureModeChanged(captureMode);
+ }
+ Log.i(TAG, "Mic-only capture for calibration VU");
+ thread = new Thread(() -> micOnlyLoop(callback), "AudioCaptureMic");
+ thread.start();
+ }
+
+ private void micOnlyLoop(Callback callback) {
+ int frameBytes = pcmFrameBytes();
+ byte[] micBuf = new byte[frameBytes];
+ byte[] copy = new byte[frameBytes];
+ long frameIndex = 0;
+ while (running) {
+ if (!readFully(micRecord, micBuf, frameBytes)) {
+ continue;
+ }
+ System.arraycopy(micBuf, 0, copy, 0, frameBytes);
+ long ptsUs = frameIndex * 20_000L;
+ frameIndex++;
+ if (callback != null) {
+ callback.onPcm(copy, frameBytes, ptsUs,
+ com.foxx.androidcast.media.PcmLevelAnalyzer.analyze(copy, frameBytes));
+ }
+ }
+ }
+
+ private static int pcmFrameBytes() {
+ return CastConfig.AUDIO_SAMPLE_RATE * CastConfig.AUDIO_CHANNEL_COUNT * 2 / 50;
+ }
+
+ private static boolean readFully(AudioRecord record, byte[] buf, int length) {
+ int off = 0;
+ while (off < length && record != null) {
+ int n = record.read(buf, off, length - off);
+ if (n <= 0) {
+ return false;
+ }
+ off += n;
+ }
+ return true;
+ }
+
private boolean startPlayback(MediaProjection projection) {
try {
playbackRecord = buildPlaybackRecord(projection);
@@ -138,40 +195,32 @@ public class AudioCapture {
}
private void mixLoop(Callback callback) {
- byte[] playBuf = new byte[4096];
- byte[] micBuf = new byte[4096];
- long startNs = System.nanoTime();
+ int frameBytes = pcmFrameBytes();
+ byte[] playBuf = new byte[frameBytes];
+ byte[] micBuf = new byte[frameBytes];
+ byte[] mixOut = new byte[frameBytes];
+ long frameIndex = 0;
while (running) {
- int pRead = playbackRecord.read(playBuf, 0, playBuf.length);
- int mRead = micRecord != null ? micRecord.read(micBuf, 0, micBuf.length) : 0;
- if (pRead < 0 && mRead < 0) {
- Log.w(TAG, "Audio read error");
+ if (!readFully(playbackRecord, playBuf, frameBytes)) {
continue;
}
- byte[] out;
- int outLen;
- if (pRead > 0) {
- out = playBuf;
- outLen = pRead;
- String mode = hasAudibleSamples(playBuf, pRead) ? "playback" : "playback-quiet";
- if (!mode.equals(captureMode)) {
- captureMode = mode;
- notifyMode(callback, captureMode);
+ int mRead = micRecord != null ? micRecord.read(micBuf, 0, frameBytes) : 0;
+ System.arraycopy(playBuf, 0, mixOut, 0, frameBytes);
+ if (mRead > 0 && !hasAudibleSamples(playBuf, frameBytes)) {
+ for (int i = 0; i < frameBytes; i++) {
+ mixOut[i] = (byte) Math.max(mixOut[i] & 0xff, micBuf[i] & 0xff);
}
- } else if (mRead > 0 && micRecord != null) {
- out = micBuf;
- outLen = mRead;
- if (!"mic".equals(captureMode)) {
- captureMode = "mic";
- notifyMode(callback, captureMode);
- }
- } else {
- continue;
}
- long ptsUs = (System.nanoTime() - startNs) / 1000;
+ String mode = hasAudibleSamples(playBuf, frameBytes) ? "playback" : "playback-quiet";
+ if (!mode.equals(captureMode)) {
+ captureMode = mode;
+ notifyMode(callback, captureMode);
+ }
+ long ptsUs = frameIndex * 20_000L;
+ frameIndex++;
if (callback != null) {
- PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(out, outLen);
- callback.onPcm(out, outLen, ptsUs, levels);
+ PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(mixOut, frameBytes);
+ callback.onPcm(mixOut, frameBytes, ptsUs, levels);
}
}
}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java b/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java
index dc13ed9..017a356 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java
@@ -85,6 +85,8 @@ public class AudioEncoder {
public void stop() {
running = false;
+ MediaCodec c = codec;
+ codec = null;
if (drainThread != null) {
try {
drainThread.join(1000);
@@ -92,13 +94,15 @@ public class AudioEncoder {
Thread.currentThread().interrupt();
}
}
- if (codec != null) {
+ if (c != null) {
try {
- codec.stop();
+ c.stop();
+ } catch (Exception ignored) {
+ }
+ try {
+ c.release();
} catch (Exception ignored) {
}
- codec.release();
- codec = null;
}
}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java b/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java
index 8c4ee0d..a7da65c 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java
@@ -11,25 +11,32 @@ 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. */
+/** Background send queue: audio has its own lane so video backlog cannot starve it. */
public final class CastSendPump {
private static final String TAG = "CastSendPump";
- private static final int MAX_QUEUE = 48;
+ private static final int MAX_VIDEO_QUEUE = 40;
+ private static final int MAX_AUDIO_QUEUE = 96;
- private final LinkedBlockingQueue queue = new LinkedBlockingQueue<>(MAX_QUEUE);
+ private final LinkedBlockingQueue videoQueue = new LinkedBlockingQueue<>(MAX_VIDEO_QUEUE);
+ private final LinkedBlockingQueue audioQueue = new LinkedBlockingQueue<>(MAX_AUDIO_QUEUE);
private final AtomicBoolean running = new AtomicBoolean(false);
private Thread thread;
private CastSession session;
private Runnable onSendFailed;
private PassThroughNetworkControlPlane statsPlane;
private volatile int droppedPFrames;
+ private volatile int droppedAudioFrames;
public int getDroppedPFrames() {
return droppedPFrames;
}
+ public int getDroppedAudioFrames() {
+ return droppedAudioFrames;
+ }
+
public int getQueueDepth() {
- return queue.size();
+ return videoQueue.size() + audioQueue.size();
}
public void start(CastSession session, Runnable onSendFailed) {
@@ -44,21 +51,23 @@ public final class CastSendPump {
return;
}
thread = new Thread(this::run, "CastSendPump");
+ thread.setPriority(Thread.NORM_PRIORITY + 1);
thread.start();
}
public void stop() {
running.set(false);
+ videoQueue.clear();
+ audioQueue.clear();
if (thread != null) {
thread.interrupt();
try {
- thread.join(1500);
+ thread.join(500);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
thread = null;
}
- queue.clear();
session = null;
}
@@ -67,17 +76,12 @@ public final class CastSendPump {
return;
}
Outgoing item = Outgoing.video(ptsUs, keyFrame, annexBReadyPayload);
- if (!queue.offer(item)) {
+ if (!videoQueue.offer(item)) {
if (!keyFrame) {
- Outgoing dropped = queue.poll();
- if (dropped != null && !dropped.keyFrame) {
- droppedPFrames++;
- }
+ dropOneVideoPFrame();
}
- if (!queue.offer(item)) {
- if (!keyFrame) {
- droppedPFrames++;
- }
+ if (!videoQueue.offer(item) && !keyFrame) {
+ droppedPFrames++;
}
}
}
@@ -87,15 +91,43 @@ public final class CastSendPump {
return;
}
Outgoing item = Outgoing.raw(type, payload);
- while (running.get() && !queue.offer(item)) {
- queue.poll();
+ boolean audio = type == CastProtocol.MSG_AUDIO_FRAME || type == CastProtocol.MSG_AUDIO_CONFIG;
+ if (audio) {
+ if (!audioQueue.offer(item)) {
+ Outgoing dropped = audioQueue.poll();
+ if (dropped != null) {
+ droppedAudioFrames++;
+ }
+ audioQueue.offer(item);
+ }
+ return;
+ }
+ while (running.get() && !videoQueue.offer(item)) {
+ dropOneVideoPFrame();
+ }
+ }
+
+ private void dropOneVideoPFrame() {
+ for (Outgoing o : videoQueue) {
+ if (o.type == CastProtocol.MSG_VIDEO_FRAME && !o.keyFrame) {
+ videoQueue.remove(o);
+ droppedPFrames++;
+ return;
+ }
+ }
+ Outgoing head = videoQueue.poll();
+ if (head != null && head.type == CastProtocol.MSG_VIDEO_FRAME && !head.keyFrame) {
+ droppedPFrames++;
}
}
private void run() {
while (running.get()) {
try {
- Outgoing item = queue.poll(200, TimeUnit.MILLISECONDS);
+ Outgoing item = audioQueue.poll();
+ if (item == null) {
+ item = videoQueue.poll(50, TimeUnit.MILLISECONDS);
+ }
if (item == null || session == null) {
continue;
}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java b/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java
index b28b997..1384a33 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java
@@ -149,18 +149,27 @@ public final class MultiCastCoordinator {
return any;
}
- public void closeAll() {
+ public void sendGoodbyeAll() {
for (ActivePeer p : peers) {
try {
p.session.send(CastProtocol.MSG_GOODBYE, new byte[0]);
} catch (IOException ignored) {
}
- p.session.close();
+ }
+ }
+
+ public void closeAll() {
+ for (ActivePeer p : peers) {
+ try {
+ p.session.close();
+ } catch (Exception ignored) {
+ }
}
peers.clear();
}
private CastSession createSession(CastSettings settings) throws IOException {
+ CastTransportFactory.init(appContext);
CastSession session = new CastSession(CastTransportFactory.create(settings));
session.getTransport().setFramingContext(framingContext);
return session;
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 8275113..c7d849c 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
@@ -36,6 +36,10 @@ import com.foxx.androidcast.network.CastTransportFactory;
import com.foxx.androidcast.network.control.NetworkFeedbackManager;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
+import com.foxx.androidcast.network.transport.TransportLossStats;
+import com.foxx.androidcast.network.transport.TransportLossStatsBridge;
+import com.foxx.androidcast.stats.SessionStatsRecorder;
+import com.foxx.androidcast.stats.SessionStatsStore;
import java.io.IOException;
import java.util.ArrayList;
@@ -46,7 +50,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
/** Foreground sender: screen + audio capture → encode → TCP/UDP (survives UI exit). */
-public class ScreenCastService extends Service {
+public class ScreenCastService extends Service implements
+ com.foxx.androidcast.sender.calibration.CalibrationCastDriver.FallbackHost {
private static final String TAG = "ScreenCastService";
private static final int CONNECT_RETRIES = 20;
private static final long CONNECT_RETRY_MS = 1000;
@@ -58,12 +63,14 @@ public class ScreenCastService extends Service {
public static final String EXTRA_PIN = "pin";
public static final String EXTRA_DEVICE_NAME = "device_name";
public static final String EXTRA_TARGETS = "cast_targets";
+ public static final String EXTRA_CALIBRATION = "calibration_mode";
public static final String ACTION_STOP = "com.foxx.androidcast.STOP_SEND";
private final RemoteCallbackList callbacks = new RemoteCallbackList<>();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private final AtomicBoolean stopping = new AtomicBoolean(false);
private final AtomicBoolean casting = new AtomicBoolean(false);
+ private final AtomicBoolean resourcesReleased = new AtomicBoolean(false);
private MediaProjection projection;
private VirtualDisplay virtualDisplay;
@@ -92,6 +99,11 @@ public class ScreenCastService extends Service {
private int captureDensity;
private DisplayManager displayManager;
private DisplayManager.DisplayListener displayListener;
+ private com.foxx.androidcast.sender.calibration.CalibrationCastDriver calibrationDriver;
+ private volatile boolean calibrationMode;
+ private volatile boolean cameraMode;
+ private com.foxx.androidcast.sender.camera.CameraCastCapture cameraCapture;
+ private SessionStatsRecorder sessionStatsRecorder;
private final ICastSenderService.Stub binder = new ICastSenderService.Stub() {
@Override
@@ -128,7 +140,9 @@ public class ScreenCastService extends Service {
@Override
public void onCreate() {
super.onCreate();
+ SessionStatsStore.pruneOnAppStart(this);
CastNotifications.createChannels(this);
+ com.foxx.androidcast.network.CastTransportFactory.init(this);
}
@Override
@@ -149,6 +163,8 @@ public class ScreenCastService extends Service {
}
private void runCast(Intent intent) {
+ resourcesReleased.set(false);
+ stopping.set(false);
if (intent == null) {
failAndStop(getString(R.string.error_no_intent));
return;
@@ -161,7 +177,11 @@ public class ScreenCastService extends Service {
String deviceName = intent.getStringExtra(EXTRA_DEVICE_NAME);
CastSettings settings = IntentExtras.getCastSettings(intent);
- if (data == null) {
+ boolean calibration = settings != null
+ && settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST;
+ boolean camera = settings != null
+ && settings.getCaptureMode() == CastSettings.CaptureMode.CAMERA;
+ if (!calibration && !camera && data == null) {
failAndStop(getString(R.string.error_projection_data));
return;
}
@@ -169,6 +189,16 @@ public class ScreenCastService extends Service {
failAndStop(getString(R.string.error_missing_params));
return;
}
+ cameraMode = settings.getCaptureMode() == CastSettings.CaptureMode.CAMERA;
+ calibrationMode = settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST;
+ if (cameraMode && !com.foxx.androidcast.PlatformCastSupport.isCameraCaptureModeSupported()) {
+ failAndStop(getString(R.string.capture_camera_not_implemented));
+ return;
+ }
+ int castPort = com.foxx.androidcast.CastConfig.portForTransport(settings.getTransport());
+ if (port <= 0 || port == com.foxx.androidcast.CastConfig.CAST_PORT) {
+ port = castPort;
+ }
acquireWakeLock();
try {
@@ -210,7 +240,17 @@ public class ScreenCastService extends Service {
casting.set(true);
CastActiveState.setSenderCasting(true);
activeSettings = settings;
- startCaptureOnMainThread(resultCode, new Intent(data), settings);
+ sessionStatsRecorder = new SessionStatsRecorder("sender", settings.getTransport());
+ sessionStatsRecorder.mergeSettings(settings);
+ sessionStatsRecorder.setClientCount(peers.size());
+ sessionStatsRecorder.setCodecs(settings.getNegotiatedVideoMime(), "AAC");
+ if (calibration) {
+ startCalibrationCaptureOnMainThread(settings);
+ } else if (camera) {
+ startCameraCaptureOnMainThread(settings);
+ } else {
+ startCaptureOnMainThread(resultCode, new Intent(data), settings);
+ }
scheduleTuningTick();
while (!stopping.get()) {
try {
@@ -285,6 +325,83 @@ public class ScreenCastService extends Service {
throw new IOException(getString(R.string.error_connect_failed, host, port), last);
}
+ private void startCalibrationCaptureOnMainThread(CastSettings settings) throws IOException {
+ if (Looper.myLooper() != mainHandler.getLooper()) {
+ CountDownLatch latch = new CountDownLatch(1);
+ AtomicReference error = new AtomicReference<>();
+ mainHandler.post(() -> {
+ try {
+ startCalibrationCaptureOnMainThread(settings);
+ } catch (Exception e) {
+ error.set(e instanceof IOException ? (IOException) e : new IOException(e.getMessage(), e));
+ } finally {
+ latch.countDown();
+ }
+ });
+ try {
+ if (!latch.await(15, TimeUnit.SECONDS)) {
+ throw new IOException("Calibration setup timeout");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("Interrupted", e);
+ }
+ if (error.get() != null) {
+ throw error.get();
+ }
+ return;
+ }
+ CastResolution.Size size = CastResolution.calibrationTestSize();
+ setupVideoPipeline(size, settings, true);
+ startAudioPipeline(settings, true);
+ updateStatus(getString(R.string.casting_calibration, captureWidth, captureHeight));
+ }
+
+ private void startCameraCaptureOnMainThread(CastSettings settings) throws IOException {
+ if (Looper.myLooper() != mainHandler.getLooper()) {
+ CountDownLatch latch = new CountDownLatch(1);
+ AtomicReference error = new AtomicReference<>();
+ mainHandler.post(() -> {
+ try {
+ startCameraCaptureOnMainThread(settings);
+ } catch (Exception e) {
+ error.set(e instanceof IOException ? (IOException) e : new IOException(e.getMessage(), e));
+ } finally {
+ latch.countDown();
+ }
+ });
+ try {
+ if (!latch.await(20, TimeUnit.SECONDS)) {
+ throw new IOException("Camera setup timeout");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("Interrupted", e);
+ }
+ if (error.get() != null) {
+ throw error.get();
+ }
+ return;
+ }
+ DisplayMetrics metrics = new DisplayMetrics();
+ WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
+ wm.getDefaultDisplay().getRealMetrics(metrics);
+ CastResolution.Size size = CastResolution.compute(metrics, settings, getPeerStats());
+ setupVideoPipeline(size, settings, false);
+ Surface surface = videoEncoder != null ? videoEncoder.getInputSurface() : null;
+ if (surface == null) {
+ throw new IOException("Encoder surface unavailable for camera");
+ }
+ cameraCapture = new com.foxx.androidcast.sender.camera.CameraCastCapture(this);
+ try {
+ cameraCapture.start(surface, captureWidth, captureHeight);
+ } catch (android.hardware.camera2.CameraAccessException e) {
+ throw new IOException("Camera open failed: " + e.getMessage(), e);
+ }
+ startAudioPipeline(settings, true);
+ updateStatus(getString(R.string.casting_camera, captureWidth, captureHeight));
+ }
+
private void startCaptureOnMainThread(int resultCode, Intent data, CastSettings settings) throws IOException {
if (Looper.myLooper() != mainHandler.getLooper()) {
CountDownLatch latch = new CountDownLatch(1);
@@ -335,16 +452,17 @@ public class ScreenCastService extends Service {
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
wm.getDefaultDisplay().getRealMetrics(metrics);
CastResolution.Size size = CastResolution.compute(metrics, settings, getPeerStats());
- setupVideoPipeline(size, settings);
+ setupVideoPipeline(size, settings, false);
registerDisplayListener();
- startAudioPipeline(settings);
+ startAudioPipeline(settings, false);
String mode = session.getTransport().getModeLabel();
updateStatus(getString(R.string.casting_active, mode, captureWidth, captureHeight));
}
- private void setupVideoPipeline(CastResolution.Size size, CastSettings settings) throws IOException {
+ private void setupVideoPipeline(CastResolution.Size size, CastSettings settings, boolean calibration)
+ throws IOException {
if (settings.isPassthrough()) {
throw new IOException("Passthrough video is not implemented yet (debug only)");
}
@@ -376,45 +494,29 @@ public class ScreenCastService extends Service {
}
videoEncoder = new VideoEncoder();
- VideoEncoder.Callback videoCb = new VideoEncoder.Callback() {
- @Override
- public void onConfig(int w, int h, byte[] csd0, byte[] csd1) {
- if (stopping.get()) {
- return;
- }
- sendSafe(CastProtocol.MSG_STREAM_CONFIG,
- () -> CastProtocol.streamConfigPayload(w, h, csd0, csd1));
- }
+ VideoEncoder.Callback videoCb = buildVideoEncoderCallback(mime);
- @Override
- public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
- if ((sendPump == null && fanoutPump == null) || frameData == null) {
- return;
- }
- boolean tiny = effectiveParams != null && effectiveParams.skipTinyPFrames
- && !keyFrame
- && frameData.length < CastConfig.MIN_SIGNIFICANT_FRAME_BYTES;
- if (tiny) {
- return;
- }
- lastSignificantFrameMs = System.currentTimeMillis();
- senderMetrics.onEncodedFrame(keyFrame);
- enqueueVideoFrame(ptsUs, keyFrame, frameData);
- }
- };
+ if (calibration) {
+ startCalibrationVideoEncoder(mime, effectiveParams, videoCb, true);
+ return;
+ }
Surface surface = videoEncoder.prepare(captureWidth, captureHeight, mime, effectiveParams, videoCb);
encoderKeepalive = new EncoderKeepalive();
encoderKeepalive.start(videoEncoder);
- virtualDisplay = projection.createVirtualDisplay(
- "AndroidCast",
- captureWidth,
- captureHeight,
- captureDensity,
- DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
- surface,
- null,
- null);
+ if (projection != null) {
+ virtualDisplay = projection.createVirtualDisplay(
+ "AndroidCast",
+ captureWidth,
+ captureHeight,
+ captureDensity,
+ DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
+ surface,
+ null,
+ null);
+ } else {
+ throw new IOException("MediaProjection required for screen capture");
+ }
videoEncoder.requestKeyframe();
Log.i(TAG, "Video pipeline " + captureWidth + "x" + captureHeight);
}
@@ -450,7 +552,8 @@ public class ScreenCastService extends Service {
}
private void maybeReconfigureForRotation() {
- if (!casting.get() || projection == null || activeSettings == null || stopping.get()) {
+ if (calibrationMode || cameraMode || !casting.get() || projection == null
+ || activeSettings == null || stopping.get()) {
return;
}
DisplayMetrics metrics = new DisplayMetrics();
@@ -461,7 +564,7 @@ public class ScreenCastService extends Service {
return;
}
try {
- setupVideoPipeline(size, activeSettings);
+ setupVideoPipeline(size, activeSettings, false);
updateStatus(getString(R.string.casting_rotated, captureWidth, captureHeight));
} catch (IOException e) {
Log.e(TAG, "Rotation reconfigure failed", e);
@@ -484,6 +587,11 @@ public class ScreenCastService extends Service {
s.encodeQueueDepth = sendPump.getQueueDepth();
s.droppedPFrames = sendPump.getDroppedPFrames();
}
+ TransportLossStats snap = buildSenderLossSnapshot();
+ TransportLossStatsBridge.applyTo(s, snap);
+ if (sessionStatsRecorder != null) {
+ sessionStatsRecorder.sampleNetwork(s, getPeerStats());
+ }
s.audioActive = audioEncoder != null && audioFramesSent > 0;
s.audioFramesSent = audioFramesSent;
s.audioCaptureVuPercent = senderAudioMetrics.getVuPercent();
@@ -543,7 +651,7 @@ public class ScreenCastService extends Service {
}
private void maybeAdaptCaptureSize(NetworkStatsSnapshot peer) {
- if (activeSettings == null
+ if (calibrationMode || cameraMode || activeSettings == null
|| activeSettings.getResolution() != CastSettings.Resolution.ADAPTIVE) {
return;
}
@@ -557,19 +665,19 @@ public class ScreenCastService extends Service {
return;
}
try {
- setupVideoPipeline(size, activeSettings);
+ setupVideoPipeline(size, activeSettings, false);
Log.i(TAG, "Adaptive capture " + captureWidth + "x" + captureHeight);
} catch (IOException e) {
Log.w(TAG, "Adaptive resize failed", e);
}
}
- private void startAudioPipeline(CastSettings settings) {
+ private void startAudioPipeline(CastSettings settings, boolean calibrationOnlyMic) {
if (!settings.isAudioEnabled() || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
Log.i(TAG, "Audio capture disabled for this session");
return;
}
- if (projection == null) {
+ if (projection == null && !calibrationOnlyMic) {
Log.w(TAG, "Audio skipped — no MediaProjection");
return;
}
@@ -609,11 +717,15 @@ public class ScreenCastService extends Service {
senderAudioMetrics.reset();
audioCaptureMode = "playback";
audioCapture = new AudioCapture(this);
- audioCapture.start(projection, new AudioCapture.Callback() {
+ MediaProjection audioProjection = calibrationOnlyMic ? null : projection;
+ audioCapture.start(audioProjection, new AudioCapture.Callback() {
@Override
public void onPcm(byte[] pcm, int length, long ptsUs,
com.foxx.androidcast.media.PcmLevelAnalyzer.Levels levels) {
senderAudioMetrics.onPcm(levels);
+ if (calibrationDriver != null) {
+ calibrationDriver.setVuFromPcm(pcm, length);
+ }
if (audioEncoder != null) {
audioEncoder.queuePcm(pcm, length, ptsUs);
}
@@ -634,7 +746,8 @@ public class ScreenCastService extends Service {
private void handleAudioConsent(boolean accept) {
if (accept) {
if (audioEncoder == null && activeSettings != null) {
- startAudioPipeline(activeSettings);
+ startAudioPipeline(activeSettings, activeSettings != null
+ && activeSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST);
}
Log.i(TAG, "Receiver accepted audio");
} else {
@@ -695,6 +808,10 @@ public class ScreenCastService extends Service {
}
private void releaseAll() {
+ if (!resourcesReleased.compareAndSet(false, true)) {
+ return;
+ }
+ finishSessionStatsAsync();
stopping.set(true);
casting.set(false);
CastActiveState.setSenderCasting(false);
@@ -708,10 +825,16 @@ public class ScreenCastService extends Service {
encoderKeepalive.stop();
encoderKeepalive = null;
}
- if (videoEncoder != null) {
- videoEncoder.stop();
- videoEncoder = null;
+ if (cameraCapture != null) {
+ cameraCapture.stop();
+ cameraCapture = null;
}
+ if (calibrationDriver != null) {
+ calibrationDriver.release();
+ calibrationDriver = null;
+ }
+ calibrationMode = false;
+ cameraMode = false;
if (audioCapture != null) {
audioCapture.stop();
audioCapture = null;
@@ -720,28 +843,36 @@ public class ScreenCastService extends Service {
audioEncoder.stop();
audioEncoder = null;
}
+ if (videoEncoder != null) {
+ videoEncoder.stop();
+ videoEncoder = null;
+ }
if (projection != null) {
projection.stop();
projection = null;
}
if (multiCoordinator != null) {
- multiCoordinator.closeAll();
- multiCoordinator = null;
+ multiCoordinator.sendGoodbyeAll();
} else if (session != null) {
try {
session.send(CastProtocol.MSG_GOODBYE, new byte[0]);
} catch (Exception ignored) {
}
- session.close();
- session = null;
+ }
+ if (sendPump != null) {
+ sendPump.stop();
+ sendPump = null;
}
if (fanoutPump != null) {
fanoutPump.stop();
fanoutPump = null;
}
- if (sendPump != null) {
- sendPump.stop();
- sendPump = null;
+ if (multiCoordinator != null) {
+ multiCoordinator.closeAll();
+ multiCoordinator = null;
+ } else if (session != null) {
+ session.close();
+ session = null;
}
releaseWakeLock();
}
@@ -752,10 +883,17 @@ public class ScreenCastService extends Service {
}
private void stopSelfSafely() {
- stopping.set(true);
- releaseAll();
- stopForeground(STOP_FOREGROUND_REMOVE);
- stopSelf();
+ if (!stopping.compareAndSet(false, true)) {
+ return;
+ }
+ Thread stopper = new Thread(() -> {
+ releaseAll();
+ mainHandler.post(() -> {
+ stopForeground(STOP_FOREGROUND_REMOVE);
+ stopSelf();
+ });
+ }, "CastStop");
+ stopper.start();
}
private void startSenderForeground(String text, boolean withAudio) {
@@ -771,6 +909,41 @@ public class ScreenCastService extends Service {
}
}
+ private TransportLossStats buildSenderLossSnapshot() {
+ TransportLossStats snap = session != null
+ ? session.getTransport().getLossStats() : new TransportLossStats();
+ if (sendPump != null) {
+ snap.sendDroppedVideoP = sendPump.getDroppedPFrames();
+ snap.sendDroppedAudio = sendPump.getDroppedAudioFrames();
+ snap.sendQueueDepth = sendPump.getQueueDepth();
+ } else if (fanoutPump != null) {
+ snap.sendDroppedVideoP = fanoutPump.getDroppedPFrames();
+ snap.sendQueueDepth = fanoutPump.getQueueDepth();
+ }
+ return snap;
+ }
+
+ private void finishSessionStatsAsync() {
+ final SessionStatsRecorder recorder = sessionStatsRecorder;
+ sessionStatsRecorder = null;
+ if (recorder == null) {
+ return;
+ }
+ if (activeSettings != null) {
+ recorder.setResolution(captureWidth, captureHeight);
+ recorder.setCaptureMode(activeSettings.getCaptureMode() != null
+ ? activeSettings.getCaptureMode().name() : "");
+ }
+ recorder.mergeLoss(buildSenderLossSnapshot());
+ new Thread(() -> recorder.finish(getApplicationContext()), "SessionStatsSave").start();
+ }
+
+ private void logEncodedCodecSample(String mime, byte[] frameData) {
+ String probe = com.foxx.androidcast.media.EncodedStreamProbe.describeKeyframe(mime, frameData);
+ Log.i(TAG, "Encoded stream: " + com.foxx.androidcast.media.CodecNegotiator.displayName(mime)
+ + " — " + probe);
+ }
+
private void updateStatus(String text) {
status = text;
broadcastStatus(text);
@@ -790,6 +963,90 @@ public class ScreenCastService extends Service {
callbacks.finishBroadcast();
}
+ private VideoEncoder.Callback buildVideoEncoderCallback(String mime) {
+ return new VideoEncoder.Callback() {
+ @Override
+ public void onConfig(int w, int h, byte[] csd0, byte[] csd1) {
+ if (stopping.get()) {
+ return;
+ }
+ sendSafe(CastProtocol.MSG_STREAM_CONFIG,
+ () -> CastProtocol.streamConfigPayload(w, h, csd0, csd1));
+ }
+
+ @Override
+ public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
+ if ((sendPump == null && fanoutPump == null) || frameData == null) {
+ return;
+ }
+ boolean tiny = effectiveParams != null && effectiveParams.skipTinyPFrames
+ && !keyFrame
+ && frameData.length < CastConfig.MIN_SIGNIFICANT_FRAME_BYTES;
+ if (tiny) {
+ return;
+ }
+ lastSignificantFrameMs = System.currentTimeMillis();
+ senderMetrics.onEncodedFrame(keyFrame);
+ if (keyFrame) {
+ logEncodedCodecSample(mime, frameData);
+ }
+ enqueueVideoFrame(ptsUs, keyFrame, frameData);
+ }
+ };
+ }
+
+ private void startCalibrationVideoEncoder(String mime, CastTuningEngine.EffectiveParams params,
+ VideoEncoder.Callback videoCb, boolean tryGles) throws IOException {
+ if (encoderKeepalive != null) {
+ encoderKeepalive.stop();
+ encoderKeepalive = null;
+ }
+ if (calibrationDriver != null) {
+ calibrationDriver.release();
+ calibrationDriver = null;
+ }
+ if (videoEncoder != null) {
+ videoEncoder.stop();
+ }
+ videoEncoder = new VideoEncoder();
+ float density = getResources().getDisplayMetrics().density;
+ calibrationDriver = new com.foxx.androidcast.sender.calibration.CalibrationCastDriver(this, this);
+ if (tryGles) {
+ Surface surface = videoEncoder.prepare(captureWidth, captureHeight, mime, params, videoCb);
+ encoderKeepalive = new EncoderKeepalive();
+ encoderKeepalive.start(videoEncoder);
+ calibrationDriver.attachSurface(videoEncoder, surface, captureWidth, captureHeight, density);
+ videoEncoder.requestKeyframe();
+ Log.i(TAG, "Calibration encoder (GLES try) " + captureWidth + "x" + captureHeight + " mime=" + mime);
+ } else {
+ videoEncoder.prepareBufferInput(captureWidth, captureHeight, mime, params, videoCb);
+ encoderKeepalive = new EncoderKeepalive();
+ encoderKeepalive.start(videoEncoder);
+ calibrationDriver.attachBuffer(videoEncoder, captureWidth, captureHeight, density);
+ videoEncoder.requestKeyframe();
+ Log.i(TAG, "Calibration encoder (buffer) " + captureWidth + "x" + captureHeight + " mime=" + mime);
+ }
+ }
+
+ @Override
+ public void onCalibrationNeedBufferEncoder() {
+ if (!calibrationMode || stopping.get() || activeSettings == null) {
+ return;
+ }
+ String mime = activeSettings.getNegotiatedVideoMime();
+ if (mime == null || mime.isEmpty()) {
+ return;
+ }
+ mainHandler.post(() -> {
+ try {
+ startCalibrationVideoEncoder(mime, effectiveParams, buildVideoEncoderCallback(mime), false);
+ } catch (IOException e) {
+ Log.e(TAG, "Calibration buffer fallback failed", e);
+ failAndStop("Calibration video failed");
+ }
+ });
+ }
+
@Override
public IBinder onBind(Intent intent) {
return binder;
diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java
index 2e9098e..140e49c 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java
@@ -14,6 +14,7 @@ import android.provider.Settings;
import android.text.TextUtils;
import android.view.View;
import android.view.WindowManager;
+import android.widget.Button;
import android.widget.EditText;
import android.widget.ListView;
import android.widget.ProgressBar;
@@ -35,7 +36,6 @@ import com.foxx.androidcast.discovery.DiscoveryManager;
public class SenderActivity extends AppCompatActivity {
private static final int REQUEST_MEDIA_PROJECTION = 2001;
- private static final long DISCOVERY_INTERVAL_MS = 30_000;
private static final long BACK_STOP_INTERVAL_MS = 2_000;
private static final int DEV_TAP_UNLOCK = 7;
@@ -47,7 +47,28 @@ public class SenderActivity extends AppCompatActivity {
private TextView statusText;
private TextView titleText;
private ProgressBar discoveryProgress;
+ private Button refreshButton;
private View advancedPanel;
+
+ private enum DiscoveryPhase {
+ IDLE, SCANNING, PAUSED
+ }
+
+ private DiscoveryPhase discoveryPhase = DiscoveryPhase.IDLE;
+ private int discoverySecondsLeft;
+ private final Runnable autoScanRunnable = () -> beginScanPass(false);
+ private final Runnable discoveryCountdownTick = new Runnable() {
+ @Override
+ public void run() {
+ if (discoveryPhase == DiscoveryPhase.SCANNING || discoveryPhase == DiscoveryPhase.PAUSED) {
+ if (discoverySecondsLeft > 0) {
+ discoverySecondsLeft--;
+ }
+ updateRefreshButtonLabel();
+ handler.postDelayed(this, 1000);
+ }
+ }
+ };
private EditText pinInput;
private EditText manualHostInput;
private EditText manualPortInput;
@@ -57,15 +78,6 @@ public class SenderActivity extends AppCompatActivity {
private boolean useManualConnect;
private long lastBackPressMs;
- private final Runnable discoveryWatchdog = new Runnable() {
- @Override
- public void run() {
- if (discovery != null) {
- restartDiscoveryInternal(false);
- }
- handler.postDelayed(this, DISCOVERY_INTERVAL_MS);
- }
- };
@Override
protected void onCreate(Bundle savedInstanceState) {
@@ -79,6 +91,7 @@ public class SenderActivity extends AppCompatActivity {
pinInput.setText(CastConfig.DEFAULT_PIN);
statusText = findViewById(R.id.text_status);
discoveryProgress = findViewById(R.id.progress_discovery);
+ refreshButton = findViewById(R.id.btn_refresh);
advancedPanel = findViewById(R.id.panel_advanced);
manualHostInput = findViewById(R.id.edit_manual_host);
manualPortInput = findViewById(R.id.edit_manual_port);
@@ -143,12 +156,22 @@ public class SenderActivity extends AppCompatActivity {
@Override
public void onScanStarted() {
+ discoveryPhase = DiscoveryPhase.SCANNING;
+ discoverySecondsLeft = (int) (CastConfig.DISCOVERY_SCAN_PASS_MS / 1000);
+ updateRefreshButtonLabel();
+ handler.removeCallbacks(discoveryCountdownTick);
+ handler.post(discoveryCountdownTick);
showDiscoveryProgress();
}
@Override
public void onScanEnded() {
hideDiscoveryProgress();
+ discoveryPhase = DiscoveryPhase.PAUSED;
+ discoverySecondsLeft = (int) (CastConfig.DISCOVERY_PAUSE_MS / 1000);
+ updateRefreshButtonLabel();
+ handler.removeCallbacks(autoScanRunnable);
+ handler.postDelayed(autoScanRunnable, CastConfig.DISCOVERY_PAUSE_MS);
}
});
@@ -170,7 +193,7 @@ public class SenderActivity extends AppCompatActivity {
}
});
- findViewById(R.id.btn_refresh).setOnClickListener(v -> restartDiscovery());
+ refreshButton.setOnClickListener(v -> onRefreshClicked());
findViewById(R.id.btn_start_cast).setOnClickListener(v ->
PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow));
findViewById(R.id.btn_manual_cast).setOnClickListener(v -> {
@@ -188,36 +211,60 @@ public class SenderActivity extends AppCompatActivity {
@Override
protected void onStart() {
super.onStart();
- restartDiscovery();
- handler.postDelayed(discoveryWatchdog, DISCOVERY_INTERVAL_MS);
+ beginScanPass(false);
}
@Override
protected void onStop() {
- handler.removeCallbacks(discoveryWatchdog);
+ handler.removeCallbacks(discoveryCountdownTick);
+ handler.removeCallbacks(autoScanRunnable);
if (discovery != null) {
discovery.stop();
}
+ discoveryPhase = DiscoveryPhase.IDLE;
+ updateRefreshButtonLabel();
hideDiscoveryProgress();
super.onStop();
}
- private void restartDiscovery() {
- handler.removeCallbacks(discoveryWatchdog);
- restartDiscoveryInternal(true);
- handler.postDelayed(discoveryWatchdog, DISCOVERY_INTERVAL_MS);
+ private void onRefreshClicked() {
+ if (discoveryPhase == DiscoveryPhase.SCANNING) {
+ return;
+ }
+ handler.removeCallbacks(autoScanRunnable);
+ beginScanPass(true);
}
- private void restartDiscoveryInternal(boolean userRefresh) {
- deviceAdapter.clearDevices();
- statusText.setText(userRefresh ? R.string.rescanning : R.string.searching);
+ private void beginScanPass(boolean userInitiated) {
+ if (discovery != null && discovery.isBrowsing()) {
+ return;
+ }
+ if (userInitiated) {
+ deviceAdapter.clearDevices();
+ statusText.setText(R.string.rescanning);
+ } else if (deviceAdapter.getCount() == 0) {
+ statusText.setText(R.string.searching);
+ }
if (discovery != null) {
discovery.stop();
- discovery.clearDevices();
+ if (userInitiated) {
+ discovery.clearDevices();
+ }
}
discovery.startBrowsing();
}
+ private void updateRefreshButtonLabel() {
+ if (refreshButton == null) {
+ return;
+ }
+ if (discoveryPhase == DiscoveryPhase.SCANNING || discoveryPhase == DiscoveryPhase.PAUSED) {
+ refreshButton.setText(getString(R.string.refresh_countdown, Math.max(0, discoverySecondsLeft)));
+ } else {
+ refreshButton.setText(R.string.refresh);
+ }
+ }
+
private void showDiscoveryProgress() {
discoveryProgress.setVisibility(View.VISIBLE);
discoveryProgress.bringToFront();
@@ -303,6 +350,21 @@ public class SenderActivity extends AppCompatActivity {
}
pendingPin = pin;
+ if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST) {
+ launchCastService(null, Activity.RESULT_OK);
+ return;
+ }
+ if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CAMERA) {
+ if (androidx.core.content.ContextCompat.checkSelfPermission(this,
+ android.Manifest.permission.CAMERA) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
+ androidx.core.app.ActivityCompat.requestPermissions(this,
+ new String[] {android.Manifest.permission.CAMERA}, PermissionHelper.REQUEST_CODE);
+ pendingCameraLaunch = true;
+ return;
+ }
+ launchCastService(null, Activity.RESULT_OK);
+ return;
+ }
MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
startActivityForResult(createScreenCaptureIntent(mgr), REQUEST_MEDIA_PROJECTION);
}
@@ -316,39 +378,61 @@ public class SenderActivity extends AppCompatActivity {
return mgr.createScreenCaptureIntent();
}
+ private boolean pendingCameraLaunch;
+
private String pendingPin;
private String pendingHost;
private int pendingPort;
private String pendingDeviceName;
private java.util.ArrayList pendingTargets;
+ @Override
+ public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (requestCode == PermissionHelper.REQUEST_CODE && pendingCameraLaunch) {
+ pendingCameraLaunch = false;
+ if (grantResults.length > 0 && grantResults[0] == android.content.pm.PackageManager.PERMISSION_GRANTED) {
+ launchCastService(null, Activity.RESULT_OK);
+ } else {
+ Toast.makeText(this, R.string.capture_camera_not_implemented, Toast.LENGTH_LONG).show();
+ }
+ }
+ }
+
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode != REQUEST_MEDIA_PROJECTION) {
return;
}
- if (resultCode != Activity.RESULT_OK || data == null || pendingHost == null) {
+ if (resultCode != Activity.RESULT_OK || pendingHost == null) {
Toast.makeText(this, R.string.capture_denied, Toast.LENGTH_SHORT).show();
return;
}
+ launchCastService(data, resultCode);
+ }
+
+ private void launchCastService(Intent projectionData, int resultCode) {
String deviceName = Settings.Global.getString(getContentResolver(), Settings.Global.DEVICE_NAME);
if (deviceName == null) {
deviceName = Build.MODEL;
}
-
Intent serviceIntent = new Intent(this, ScreenCastService.class);
serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_CODE, resultCode);
- serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_DATA, new Intent(data));
+ if (projectionData != null) {
+ serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_DATA, new Intent(projectionData));
+ }
serviceIntent.putExtra(ScreenCastService.EXTRA_HOST, pendingHost);
serviceIntent.putExtra(ScreenCastService.EXTRA_PORT, pendingPort);
serviceIntent.putExtra(ScreenCastService.EXTRA_PIN, pendingPin);
serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, pendingDeviceName != null ? pendingDeviceName : deviceName);
serviceIntent.putExtra(CastSettings.EXTRA, castSettings);
+ if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST) {
+ serviceIntent.putExtra(ScreenCastService.EXTRA_CALIBRATION, true);
+ }
if (pendingTargets != null && !pendingTargets.isEmpty()) {
serviceIntent.putExtra(ScreenCastService.EXTRA_TARGETS, pendingTargets);
}
-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent);
} else {
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 7066c04..015f952 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java
@@ -1,5 +1,6 @@
package com.foxx.androidcast.sender;
+import android.graphics.Bitmap;
import android.media.MediaCodec;
import android.media.MediaCodecInfo;
import android.media.MediaFormat;
@@ -9,6 +10,7 @@ import android.util.Log;
import android.view.Surface;
import com.foxx.androidcast.CastTuningEngine;
+import com.foxx.androidcast.media.RgbToYuv420;
import java.io.IOException;
import java.nio.ByteBuffer;
@@ -25,6 +27,9 @@ public class VideoEncoder {
private MediaCodec codec;
private Surface inputSurface;
+ private boolean bufferInput;
+ private RgbToYuv420.Layout yuvLayout = RgbToYuv420.Layout.I420;
+ private byte[] yuvScratch;
private Callback callback;
private int width;
private int height;
@@ -32,7 +37,10 @@ public class VideoEncoder {
private Thread drainThread;
private String mime;
- /** Request an IDR (keyframe) on the next opportunity. */
+ public Surface getInputSurface() {
+ return inputSurface;
+ }
+
public void requestKeyframe() {
if (codec == null || !running) {
return;
@@ -48,19 +56,36 @@ public class VideoEncoder {
public Surface prepare(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
Callback callback) throws IOException {
+ return prepareInternal(width, height, mime, params, callback, false);
+ }
+
+ /** Software-fed frames (calibration chart) via YUV buffers — no input Surface. */
+ public void prepareBufferInput(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
+ Callback callback) throws IOException {
+ prepareInternal(width, height, mime, params, callback, true);
+ }
+
+ private Surface prepareInternal(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
+ Callback callback, boolean buffer) throws IOException {
this.width = width;
this.height = height;
this.mime = mime;
this.callback = callback;
-
- MediaFormat tuned = buildFormat(width, height, mime, params, true);
- MediaFormat basic = buildFormat(width, height, mime, params, false);
+ this.bufferInput = buffer;
+ yuvScratch = buffer ? new byte[RgbToYuv420.bufferSize(width, height)] : null;
codec = MediaCodec.createEncoderByType(mime);
+ yuvLayout = buffer ? pickYuvLayout(codec, mime) : RgbToYuv420.Layout.I420;
+
+ MediaFormat tuned = buildFormat(width, height, mime, params, true, buffer, yuvLayout);
+ MediaFormat basic = buildFormat(width, height, mime, params, false, buffer, yuvLayout);
+
if (!tryConfigure(codec, tuned)) {
Log.w(TAG, "Tuned encoder format rejected; retrying with basic format");
codec.release();
codec = MediaCodec.createEncoderByType(mime);
+ yuvLayout = buffer ? pickYuvLayout(codec, mime) : RgbToYuv420.Layout.I420;
+ basic = buildFormat(width, height, mime, params, false, buffer, yuvLayout);
if (!tryConfigure(codec, basic)) {
codec.release();
codec = null;
@@ -68,20 +93,73 @@ public class VideoEncoder {
}
}
- inputSurface = codec.createInputSurface();
+ if (bufferInput) {
+ inputSurface = null;
+ } else {
+ inputSurface = codec.createInputSurface();
+ }
codec.start();
running = true;
drainThread = new Thread(this::drainLoop, "VideoEncoderDrain");
drainThread.start();
- Log.i(TAG, "Encoder " + mime + " " + width + "x" + height + " @ " + params.videoFps + "fps "
- + (params.videoBitrate / 1000) + "kbps GOP=" + params.keyframeIntervalSec);
+ Log.i(TAG, (bufferInput ? "Buffer/" + yuvLayout : "Surface") + " encoder " + mime
+ + " " + width + "x" + height + " @ " + params.videoFps + "fps");
return inputSurface;
}
+ private static RgbToYuv420.Layout pickYuvLayout(MediaCodec codec, String mime) {
+ MediaCodecInfo.CodecCapabilities caps = codec.getCodecInfo().getCapabilitiesForType(mime);
+ for (int fmt : caps.colorFormats) {
+ if (fmt == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar) {
+ return RgbToYuv420.Layout.NV12;
+ }
+ }
+ return RgbToYuv420.Layout.I420;
+ }
+
+ public void queueBitmapFrame(Bitmap bitmap, long ptsUs) {
+ if (!bufferInput || codec == null || !running || bitmap == null) {
+ return;
+ }
+ byte[] yuv = RgbToYuv420.fromBitmap(bitmap, yuvLayout);
+ queueYuvFrame(yuv, ptsUs);
+ }
+
+ public void queueYuvFrame(byte[] yuv, long ptsUs) {
+ if (!bufferInput || codec == null || !running || yuv == null) {
+ return;
+ }
+ try {
+ int index = codec.dequeueInputBuffer(0);
+ if (index < 0) {
+ return;
+ }
+ ByteBuffer buffer = codec.getInputBuffer(index);
+ if (buffer == null) {
+ return;
+ }
+ buffer.clear();
+ int len = Math.min(buffer.remaining(), yuv.length);
+ buffer.put(yuv, 0, len);
+ codec.queueInputBuffer(index, 0, len, ptsUs, 0);
+ } catch (Exception e) {
+ Log.w(TAG, "queueYuvFrame: " + e.getMessage());
+ }
+ }
+
private static MediaFormat buildFormat(int width, int height, String mime,
- CastTuningEngine.EffectiveParams params, boolean tuned) {
+ CastTuningEngine.EffectiveParams params, boolean tuned, boolean buffer,
+ RgbToYuv420.Layout layout) {
MediaFormat format = MediaFormat.createVideoFormat(mime, width, height);
- format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
+ int color;
+ if (!buffer) {
+ color = MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface;
+ } else if (layout == RgbToYuv420.Layout.NV12) {
+ color = MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar;
+ } else {
+ color = MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar;
+ }
+ format.setInteger(MediaFormat.KEY_COLOR_FORMAT, color);
format.setInteger(MediaFormat.KEY_BIT_RATE, params.videoBitrate);
format.setInteger(MediaFormat.KEY_FRAME_RATE, params.videoFps);
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, params.keyframeIntervalSec);
@@ -117,6 +195,7 @@ public class VideoEncoder {
public void stop() {
running = false;
+ bufferInput = false;
if (drainThread != null) {
try {
drainThread.join(1500);
@@ -136,25 +215,30 @@ public class VideoEncoder {
inputSurface.release();
inputSurface = null;
}
+ yuvScratch = null;
}
private void drainLoop() {
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
boolean configSent = false;
while (running) {
- int index = codec.dequeueOutputBuffer(info, 5_000);
+ MediaCodec c = codec;
+ if (c == null) {
+ break;
+ }
+ int index = c.dequeueOutputBuffer(info, 5_000);
if (index == MediaCodec.INFO_TRY_AGAIN_LATER) {
continue;
}
if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
- sendConfig(codec.getOutputFormat(), configSent);
+ sendConfig(c.getOutputFormat(), configSent);
configSent = true;
continue;
}
if (index < 0) {
continue;
}
- ByteBuffer buffer = codec.getOutputBuffer(index);
+ ByteBuffer buffer = c.getOutputBuffer(index);
if (buffer != null && info.size > 0) {
byte[] data = new byte[info.size];
buffer.position(info.offset);
@@ -166,7 +250,7 @@ public class VideoEncoder {
callback.onEncodedFrame(info.presentationTimeUs, keyFrame, data);
}
}
- codec.releaseOutputBuffer(index, false);
+ c.releaseOutputBuffer(index, false);
if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
break;
}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationCastDriver.java b/app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationCastDriver.java
new file mode 100644
index 0000000..95cced0
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationCastDriver.java
@@ -0,0 +1,199 @@
+package com.foxx.androidcast.sender.calibration;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.util.Log;
+import android.view.Surface;
+
+import com.foxx.androidcast.media.PcmLevelAnalyzer;
+import com.foxx.androidcast.sender.VideoEncoder;
+
+/**
+ * Renders calibration chart + VU border into the video encoder (GLES surface or YUV buffer fallback).
+ */
+public final class CalibrationCastDriver {
+ private static final String TAG = "CalibrationCastDriver";
+ private static final int EGL_FAIL_THRESHOLD = 5;
+
+ public interface FallbackHost {
+ /** Recreate encoder with {@link VideoEncoder#prepareBufferInput} after GLES failure. */
+ void onCalibrationNeedBufferEncoder();
+ }
+
+ private enum FeedMode {
+ GLES_SURFACE,
+ YUV_BUFFER
+ }
+
+ private final Context appContext;
+ private final HandlerThread thread;
+ private final Handler handler;
+ private final FallbackHost fallbackHost;
+ private CalibrationEglRenderer eglRenderer;
+ private VideoEncoder videoEncoder;
+ private Bitmap chartBitmap;
+ private Bitmap frameBitmap;
+ private Canvas frameCanvas;
+ private int width;
+ private int height;
+ private float density;
+ private int scrollPx;
+ private volatile float vuLevel;
+ private volatile boolean running;
+ private Runnable tick;
+ private long encodeStartNs;
+ private FeedMode feedMode = FeedMode.YUV_BUFFER;
+ private int eglFailures;
+ private boolean fallbackRequested;
+
+ public CalibrationCastDriver(Context context) {
+ this(context, null);
+ }
+
+ public CalibrationCastDriver(Context context, FallbackHost fallbackHost) {
+ appContext = context.getApplicationContext();
+ this.fallbackHost = fallbackHost;
+ thread = new HandlerThread("CalibrationCast");
+ thread.start();
+ handler = new Handler(thread.getLooper());
+ }
+
+ /** Preferred path: GLES into encoder {@link Surface}. */
+ public void attachSurface(VideoEncoder encoder, Surface encoderSurface, int width, int height, float density) {
+ this.videoEncoder = encoder;
+ initBitmaps(width, height, density);
+ eglFailures = 0;
+ fallbackRequested = false;
+ try {
+ eglRenderer = new CalibrationEglRenderer();
+ eglRenderer.start(encoderSurface, width, height);
+ feedMode = FeedMode.GLES_SURFACE;
+ Log.i(TAG, "Calibration GLES " + width + "x" + height);
+ } catch (Exception e) {
+ Log.w(TAG, "GLES init failed: " + e.getMessage());
+ releaseEgl();
+ feedMode = FeedMode.YUV_BUFFER;
+ requestBufferEncoderFallback();
+ }
+ running = true;
+ encodeStartNs = System.nanoTime();
+ startTick();
+ }
+
+ /** Reliable path when GLES cannot use the encoder surface (e.g. EGL_BAD_ACCESS on MTK). */
+ public void attachBuffer(VideoEncoder encoder, int width, int height, float density) {
+ this.videoEncoder = encoder;
+ releaseEgl();
+ feedMode = FeedMode.YUV_BUFFER;
+ initBitmaps(width, height, density);
+ running = true;
+ encodeStartNs = System.nanoTime();
+ startTick();
+ Log.i(TAG, "Calibration buffer feed " + width + "x" + height);
+ }
+
+ private void initBitmaps(int w, int h, float density) {
+ this.width = w;
+ this.height = h;
+ this.density = density;
+ scrollPx = 0;
+ chartBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
+ Canvas chartCanvas = new Canvas(chartBitmap);
+ TvCalibrationGenerator.drawChart(chartCanvas, w, h, appContext);
+ frameBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
+ frameCanvas = new Canvas(frameBitmap);
+ }
+
+ public void setVuFromPcm(byte[] pcm, int length) {
+ PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(pcm, length);
+ float instant = Math.max(levels.peak, levels.rms);
+ if (levels.silent) {
+ vuLevel *= 0.88f;
+ } else {
+ vuLevel = instant * 0.7f + vuLevel * 0.3f;
+ }
+ }
+
+ private void startTick() {
+ tick = new Runnable() {
+ @Override
+ public void run() {
+ if (!running || frameCanvas == null) {
+ return;
+ }
+ long frameStart = System.nanoTime();
+ drawFrame();
+ scrollPx += Math.max(1, Math.round(1f * density));
+ long elapsedMs = (System.nanoTime() - frameStart) / 1_000_000L;
+ long delay = feedMode == FeedMode.GLES_SURFACE
+ ? Math.max(1L, 33L - elapsedMs)
+ : Math.max(1L, 50L - elapsedMs);
+ handler.postDelayed(this, delay);
+ }
+ };
+ handler.post(tick);
+ }
+
+ private void drawFrame() {
+ frameCanvas.drawBitmap(chartBitmap, 0f, 0f, null);
+ TvCalibrationGenerator.drawBorderOverlay(
+ frameCanvas, width, height, density, scrollPx, vuLevel);
+ long ptsUs = (System.nanoTime() - encodeStartNs) / 1000L;
+ if (feedMode == FeedMode.GLES_SURFACE && eglRenderer != null && eglRenderer.isReady()) {
+ if (eglRenderer.draw(frameBitmap, System.nanoTime())) {
+ eglFailures = 0;
+ return;
+ }
+ eglFailures++;
+ if (eglFailures >= EGL_FAIL_THRESHOLD) {
+ Log.w(TAG, "GLES feed failed " + eglFailures + " times — switching to buffer");
+ releaseEgl();
+ feedMode = FeedMode.YUV_BUFFER;
+ requestBufferEncoderFallback();
+ }
+ return;
+ }
+ if (videoEncoder != null && feedMode == FeedMode.YUV_BUFFER) {
+ videoEncoder.queueBitmapFrame(frameBitmap, ptsUs);
+ }
+ }
+
+ private void requestBufferEncoderFallback() {
+ if (fallbackRequested || fallbackHost == null) {
+ return;
+ }
+ fallbackRequested = true;
+ handler.post(() -> fallbackHost.onCalibrationNeedBufferEncoder());
+ }
+
+ private void releaseEgl() {
+ if (eglRenderer != null) {
+ eglRenderer.release();
+ eglRenderer = null;
+ }
+ }
+
+ public void stop() {
+ running = false;
+ handler.removeCallbacksAndMessages(null);
+ releaseEgl();
+ if (chartBitmap != null) {
+ chartBitmap.recycle();
+ chartBitmap = null;
+ }
+ if (frameBitmap != null) {
+ frameBitmap.recycle();
+ frameBitmap = null;
+ }
+ frameCanvas = null;
+ videoEncoder = null;
+ }
+
+ public void release() {
+ stop();
+ thread.quitSafely();
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationEglRenderer.java b/app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationEglRenderer.java
new file mode 100644
index 0000000..53a3009
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationEglRenderer.java
@@ -0,0 +1,236 @@
+package com.foxx.androidcast.sender.calibration;
+
+import android.graphics.Bitmap;
+import android.opengl.EGL14;
+import android.opengl.EGLConfig;
+import android.opengl.EGLContext;
+import android.opengl.EGLDisplay;
+import android.opengl.EGLExt;
+import android.opengl.EGLSurface;
+import android.opengl.GLES20;
+import android.opengl.GLUtils;
+import android.util.Log;
+import android.view.Surface;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.FloatBuffer;
+
+/** Draws a bitmap into a MediaCodec encoder input {@link Surface} via GLES2. */
+final class CalibrationEglRenderer {
+ private static final String TAG = "CalibrationEglRenderer";
+
+ private static final String VERT =
+ "attribute vec4 aPosition;\n"
+ + "attribute vec2 aTexCoord;\n"
+ + "varying vec2 vTexCoord;\n"
+ + "void main() {\n"
+ + " gl_Position = aPosition;\n"
+ + " vTexCoord = aTexCoord;\n"
+ + "}\n";
+
+ private static final String FRAG =
+ "precision mediump float;\n"
+ + "varying vec2 vTexCoord;\n"
+ + "uniform sampler2D uTexture;\n"
+ + "void main() {\n"
+ + " gl_FragColor = texture2D(uTexture, vTexCoord);\n"
+ + "}\n";
+
+ private static final float[] QUAD = {
+ -1f, -1f, 0f, 1f,
+ 1f, -1f, 1f, 1f,
+ -1f, 1f, 0f, 0f,
+ 1f, 1f, 1f, 0f,
+ };
+
+ private EGLDisplay eglDisplay = EGL14.EGL_NO_DISPLAY;
+ private EGLContext eglContext = EGL14.EGL_NO_CONTEXT;
+ private EGLSurface eglSurface = EGL14.EGL_NO_SURFACE;
+ private int program;
+ private int textureId;
+ private int aPosition;
+ private int aTexCoord;
+ private int uTexture;
+ private FloatBuffer quadBuffer;
+ private int width;
+ private int height;
+ /** {@link EGLExt#EGL_RECORDABLE_ANDROID} — required for MediaCodec input surfaces. */
+ private static final int EGL_RECORDABLE_ANDROID = 0x3142;
+
+ boolean isReady() {
+ return eglDisplay != EGL14.EGL_NO_DISPLAY && eglSurface != EGL14.EGL_NO_SURFACE;
+ }
+
+ void start(Surface surface, int width, int height) {
+ this.width = width;
+ this.height = height;
+ eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY);
+ if (eglDisplay == EGL14.EGL_NO_DISPLAY) {
+ throw new IllegalStateException("eglGetDisplay failed");
+ }
+ int[] version = new int[2];
+ if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) {
+ throw new IllegalStateException("eglInitialize failed");
+ }
+ EGLConfig eglConfig = chooseConfig(eglDisplay);
+ int[] contextAttribs = {EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE};
+ eglContext = EGL14.eglCreateContext(eglDisplay, eglConfig, EGL14.EGL_NO_CONTEXT, contextAttribs, 0);
+ if (eglContext == EGL14.EGL_NO_CONTEXT) {
+ throw new IllegalStateException("eglCreateContext failed");
+ }
+ int[] surfaceAttribs = {EGL14.EGL_NONE};
+ eglSurface = EGL14.eglCreateWindowSurface(eglDisplay, eglConfig, surface, surfaceAttribs, 0);
+ if (eglSurface == EGL14.EGL_NO_SURFACE) {
+ throw new IllegalStateException("eglCreateWindowSurface failed");
+ }
+ if (!makeCurrent()) {
+ throw new IllegalStateException("eglMakeCurrent failed: 0x"
+ + Integer.toHexString(EGL14.eglGetError()));
+ }
+ program = buildProgram(VERT, FRAG);
+ aPosition = GLES20.glGetAttribLocation(program, "aPosition");
+ aTexCoord = GLES20.glGetAttribLocation(program, "aTexCoord");
+ uTexture = GLES20.glGetUniformLocation(program, "uTexture");
+ int[] textures = new int[1];
+ GLES20.glGenTextures(1, textures, 0);
+ textureId = textures[0];
+ GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
+ GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
+ GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
+ GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
+ GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
+ quadBuffer = ByteBuffer.allocateDirect(QUAD.length * 4).order(ByteOrder.nativeOrder()).asFloatBuffer();
+ quadBuffer.put(QUAD).position(0);
+ GLES20.glViewport(0, 0, width, height);
+ Log.i(TAG, "EGL calibration renderer " + width + "x" + height);
+ }
+
+ /** @return false if EGL could not present this frame (caller should fall back to buffer input). */
+ boolean draw(Bitmap bitmap, long ptsNs) {
+ if (eglDisplay == EGL14.EGL_NO_DISPLAY || bitmap == null) {
+ return false;
+ }
+ if (!makeCurrent()) {
+ return false;
+ }
+ GLES20.glViewport(0, 0, width, height);
+ GLES20.glClearColor(0f, 0f, 0f, 1f);
+ GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
+ GLES20.glUseProgram(program);
+ GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
+ GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);
+ GLES20.glUniform1i(uTexture, 0);
+ quadBuffer.position(0);
+ GLES20.glVertexAttribPointer(aPosition, 2, GLES20.GL_FLOAT, false, 16, quadBuffer);
+ quadBuffer.position(2);
+ GLES20.glVertexAttribPointer(aTexCoord, 2, GLES20.GL_FLOAT, false, 16, quadBuffer);
+ GLES20.glEnableVertexAttribArray(aPosition);
+ GLES20.glEnableVertexAttribArray(aTexCoord);
+ GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
+ GLES20.glDisableVertexAttribArray(aPosition);
+ GLES20.glDisableVertexAttribArray(aTexCoord);
+ EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, ptsNs);
+ if (!EGL14.eglSwapBuffers(eglDisplay, eglSurface)) {
+ Log.w(TAG, "eglSwapBuffers failed: 0x" + Integer.toHexString(EGL14.eglGetError()));
+ return false;
+ }
+ return true;
+ }
+
+ private boolean makeCurrent() {
+ if (eglContext.equals(EGL14.eglGetCurrentContext())
+ && eglSurface.equals(EGL14.eglGetCurrentSurface(EGL14.EGL_DRAW))) {
+ return true;
+ }
+ if (!EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)) {
+ Log.w(TAG, "eglMakeCurrent failed: 0x" + Integer.toHexString(EGL14.eglGetError()));
+ return false;
+ }
+ return true;
+ }
+
+ void release() {
+ if (textureId != 0) {
+ int[] t = new int[] {textureId};
+ GLES20.glDeleteTextures(1, t, 0);
+ textureId = 0;
+ }
+ if (program != 0) {
+ GLES20.glDeleteProgram(program);
+ program = 0;
+ }
+ if (eglDisplay != EGL14.EGL_NO_DISPLAY) {
+ EGL14.eglMakeCurrent(eglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT);
+ if (eglSurface != EGL14.EGL_NO_SURFACE) {
+ EGL14.eglDestroySurface(eglDisplay, eglSurface);
+ }
+ if (eglContext != EGL14.EGL_NO_CONTEXT) {
+ EGL14.eglDestroyContext(eglDisplay, eglContext);
+ }
+ EGL14.eglTerminate(eglDisplay);
+ }
+ eglDisplay = EGL14.EGL_NO_DISPLAY;
+ eglContext = EGL14.EGL_NO_CONTEXT;
+ eglSurface = EGL14.EGL_NO_SURFACE;
+ }
+
+ private static EGLConfig chooseConfig(EGLDisplay display) {
+ int[] attribList = {
+ EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
+ EGL14.EGL_RED_SIZE, 8,
+ EGL14.EGL_GREEN_SIZE, 8,
+ EGL14.EGL_BLUE_SIZE, 8,
+ EGL14.EGL_ALPHA_SIZE, 8,
+ EGL_RECORDABLE_ANDROID, 1,
+ EGL14.EGL_NONE
+ };
+ EGLConfig[] configs = new EGLConfig[16];
+ int[] numConfigs = new int[1];
+ if (!EGL14.eglChooseConfig(display, attribList, 0, configs, 0, configs.length, numConfigs, 0)
+ || numConfigs[0] <= 0) {
+ throw new IllegalStateException("eglChooseConfig failed (recordable)");
+ }
+ for (int i = 0; i < numConfigs[0]; i++) {
+ int[] recordable = new int[1];
+ if (EGL14.eglGetConfigAttrib(display, configs[i], EGL_RECORDABLE_ANDROID, recordable, 0)
+ && recordable[0] != 0) {
+ return configs[i];
+ }
+ }
+ return configs[0];
+ }
+
+ private static int buildProgram(String vertexSource, String fragmentSource) {
+ int vs = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource);
+ int fs = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource);
+ int program = GLES20.glCreateProgram();
+ GLES20.glAttachShader(program, vs);
+ GLES20.glAttachShader(program, fs);
+ GLES20.glLinkProgram(program);
+ int[] link = new int[1];
+ GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, link, 0);
+ if (link[0] == 0) {
+ String err = GLES20.glGetProgramInfoLog(program);
+ GLES20.glDeleteProgram(program);
+ throw new IllegalStateException("Program link failed: " + err);
+ }
+ GLES20.glDeleteShader(vs);
+ GLES20.glDeleteShader(fs);
+ return program;
+ }
+
+ private static int compileShader(int type, String source) {
+ int shader = GLES20.glCreateShader(type);
+ GLES20.glShaderSource(shader, source);
+ GLES20.glCompileShader(shader);
+ int[] compiled = new int[1];
+ GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0);
+ if (compiled[0] == 0) {
+ String err = GLES20.glGetShaderInfoLog(shader);
+ GLES20.glDeleteShader(shader);
+ throw new IllegalStateException("Shader compile failed: " + err);
+ }
+ return shader;
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/calibration/TvCalibrationGenerator.java b/app/src/main/java/com/foxx/androidcast/sender/calibration/TvCalibrationGenerator.java
new file mode 100644
index 0000000..11e839e
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/sender/calibration/TvCalibrationGenerator.java
@@ -0,0 +1,159 @@
+package com.foxx.androidcast.sender.calibration;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.graphics.RectF;
+
+import com.foxx.androidcast.R;
+
+/**
+ * Draws a TV calibration chart (color bars + pluge) with gray reference areas transparent.
+ */
+public final class TvCalibrationGenerator {
+ private static final int[] BAR_COLORS = {
+ Color.WHITE, Color.YELLOW, Color.CYAN, Color.GREEN,
+ Color.MAGENTA, Color.RED, Color.BLUE
+ };
+
+ private TvCalibrationGenerator() {}
+
+ public static void drawChart(Canvas canvas, int width, int height, Context context) {
+ if (context == null) {
+ drawProceduralChart(canvas, width, height);
+ return;
+ }
+ canvas.drawColor(Color.BLACK);
+ Bitmap ref = loadReference(context);
+ if (ref != null) {
+ drawReferenceWithTransparentGray(canvas, ref, width, height);
+ ref.recycle();
+ return;
+ }
+ drawProceduralChart(canvas, width, height);
+ }
+
+ private static Bitmap loadReference(Context context) {
+ try {
+ return BitmapFactory.decodeResource(context.getResources(), R.drawable.calibration_chart_reference);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static void drawReferenceWithTransparentGray(Canvas canvas, Bitmap ref, int w, int h) {
+ Bitmap scaled = Bitmap.createScaledBitmap(ref, w, h, true);
+ Bitmap keyed = scaled.copy(Bitmap.Config.ARGB_8888, true);
+ scaled.recycle();
+ int[] pixels = new int[w * h];
+ keyed.getPixels(pixels, 0, w, 0, 0, w, h);
+ for (int i = 0; i < pixels.length; i++) {
+ int p = pixels[i];
+ int r = (p >> 16) & 0xff;
+ int g = (p >> 8) & 0xff;
+ int b = p & 0xff;
+ if (isGray(r, g, b)) {
+ pixels[i] = 0;
+ }
+ }
+ keyed.setPixels(pixels, 0, w, 0, 0, w, h);
+ canvas.drawBitmap(keyed, 0, 0, null);
+ keyed.recycle();
+ }
+
+ private static boolean isGray(int r, int g, int b) {
+ return Math.abs(r - g) < 25 && Math.abs(g - b) < 25 && r > 40 && r < 200;
+ }
+
+ private static void drawProceduralChart(Canvas canvas, int width, int height) {
+ Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ int barH = (int) (height * 0.55f);
+ int barW = width / BAR_COLORS.length;
+ for (int i = 0; i < BAR_COLORS.length; i++) {
+ paint.setColor(BAR_COLORS[i]);
+ canvas.drawRect(i * barW, 0, (i + 1) * barW, barH, paint);
+ }
+ int lowerTop = barH;
+ int cols = 8;
+ int cellW = width / cols;
+ for (int c = 0; c < cols; c++) {
+ int level = 255 - (c * 255 / (cols - 1));
+ if (c == 0 || c == cols - 1) {
+ continue;
+ }
+ paint.setColor(Color.rgb(level, level, level));
+ canvas.drawRect(c * cellW, lowerTop, (c + 1) * cellW, height, paint);
+ }
+ paint.setColor(Color.WHITE);
+ canvas.drawRect(0, lowerTop, cellW, height, paint);
+ paint.setColor(Color.BLACK);
+ canvas.drawRect((cols - 1) * cellW, lowerTop, width, height, paint);
+ }
+
+ /** Blue/white vertical stripe border with optional VU-driven stripe width (8dp border). */
+ public static void drawAnimatedBorder(Canvas canvas, int width, int height, float density,
+ int scrollPx, float vuLevel, Context context) {
+ drawChart(canvas, width, height, context);
+ drawBorderOverlay(canvas, width, height, density, scrollPx, vuLevel);
+ }
+
+ /** Draws only the animated border (chart must already be on the canvas / bitmap). */
+ public static void drawBorderOverlay(Canvas canvas, int width, int height, float density,
+ int scrollPx, float vuLevel) {
+ int borderPx = Math.max(2, Math.round(8f * density));
+ int stripe = Math.max(1, Math.round(1f * density));
+ int maxScroll = width + height;
+ int offset = scrollPx % Math.max(1, maxScroll);
+ float vu = Math.max(0f, Math.min(1f, vuLevel));
+ int blueWidth = Math.max(stripe, Math.round(stripe + vu * borderPx * 1.35f));
+ drawStripeFrame(canvas, width, height, borderPx, stripe, blueWidth, offset);
+ }
+
+ private static void drawStripeFrame(Canvas canvas, int width, int height, int border,
+ int stripe, int blueWidth, int offset) {
+ Paint paint = new Paint();
+ int cycle = stripe + blueWidth;
+ RectF top = new RectF(0, 0, width, border);
+ RectF bottom = new RectF(0, height - border, width, height);
+ RectF left = new RectF(0, 0, border, height);
+ RectF right = new RectF(width - border, 0, width, height);
+ fillStripes(canvas, paint, top, true, offset, cycle, blueWidth);
+ fillStripes(canvas, paint, bottom, true, offset, cycle, blueWidth);
+ fillStripes(canvas, paint, left, false, offset, cycle, blueWidth);
+ fillStripes(canvas, paint, right, false, offset, cycle, blueWidth);
+ }
+
+ private static void fillStripes(Canvas canvas, Paint paint, RectF area, boolean horizontal,
+ int offset, int cycle, int blueWidth) {
+ if (horizontal) {
+ int h = Math.max(1, Math.round(area.height()));
+ for (float x = area.left; x < area.right; ) {
+ int pos = (int) (x + area.top + offset);
+ int color = stripeColor(pos, cycle, blueWidth);
+ float next = Math.min(area.right, x + 1f);
+ paint.setColor(color);
+ canvas.drawRect(x, area.top, next, area.top + h, paint);
+ x = next;
+ }
+ } else {
+ int w = Math.max(1, Math.round(area.width()));
+ for (float y = area.top; y < area.bottom; ) {
+ int pos = (int) (area.left + y + offset);
+ int color = stripeColor(pos, cycle, blueWidth);
+ float next = Math.min(area.bottom, y + 1f);
+ paint.setColor(color);
+ canvas.drawRect(area.left, y, area.left + w, next, paint);
+ y = next;
+ }
+ }
+ }
+
+ private static int stripeColor(int pos, int cycle, int blueWidth) {
+ int m = pos % cycle;
+ return m < blueWidth ? Color.rgb(30, 144, 255) : Color.WHITE;
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/camera/CameraCastCapture.java b/app/src/main/java/com/foxx/androidcast/sender/camera/CameraCastCapture.java
new file mode 100644
index 0000000..434b4b7
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/sender/camera/CameraCastCapture.java
@@ -0,0 +1,156 @@
+package com.foxx.androidcast.sender.camera;
+
+import android.content.Context;
+import android.graphics.SurfaceTexture;
+import android.hardware.camera2.CameraAccessException;
+import android.hardware.camera2.CameraCaptureSession;
+import android.hardware.camera2.CameraCharacteristics;
+import android.hardware.camera2.CameraDevice;
+import android.hardware.camera2.CameraManager;
+import android.hardware.camera2.CaptureRequest;
+import android.hardware.camera2.params.StreamConfigurationMap;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.util.Log;
+import android.util.Size;
+import android.view.Surface;
+
+import java.util.Collections;
+
+/** Camera2 preview into the video encoder input {@link Surface}. */
+public final class CameraCastCapture {
+ private static final String TAG = "CameraCastCapture";
+
+ private final Context appContext;
+ private HandlerThread thread;
+ private Handler handler;
+ private CameraDevice camera;
+ private CameraCaptureSession session;
+ private String cameraId;
+
+ public CameraCastCapture(Context context) {
+ appContext = context.getApplicationContext();
+ }
+
+ public void start(Surface encoderSurface, int targetWidth, int targetHeight) throws CameraAccessException {
+ stop();
+ thread = new HandlerThread("CameraCast");
+ thread.start();
+ handler = new Handler(thread.getLooper());
+ CameraManager mgr = appContext.getSystemService(CameraManager.class);
+ cameraId = chooseCameraId(mgr);
+ if (cameraId == null) {
+ throw new CameraAccessException(CameraAccessException.CAMERA_ERROR,
+ "No back camera available");
+ }
+ Size preview = choosePreviewSize(mgr, cameraId, targetWidth, targetHeight);
+ Log.i(TAG, "Opening camera " + cameraId + " preview " + preview.getWidth() + "x" + preview.getHeight());
+ mgr.openCamera(cameraId, new CameraDevice.StateCallback() {
+ @Override
+ public void onOpened(CameraDevice device) {
+ camera = device;
+ try {
+ createSession(device, encoderSurface, preview);
+ } catch (CameraAccessException e) {
+ Log.e(TAG, "Session failed", e);
+ stop();
+ }
+ }
+
+ @Override
+ public void onDisconnected(CameraDevice device) {
+ device.close();
+ camera = null;
+ }
+
+ @Override
+ public void onError(CameraDevice device, int error) {
+ Log.e(TAG, "Camera error " + error);
+ device.close();
+ camera = null;
+ }
+ }, handler);
+ }
+
+ private void createSession(CameraDevice device, Surface encoderSurface, Size preview)
+ throws CameraAccessException {
+ CaptureRequest.Builder builder = device.createCaptureRequest(CameraDevice.TEMPLATE_RECORD);
+ builder.addTarget(encoderSurface);
+ device.createCaptureSession(Collections.singletonList(encoderSurface),
+ new CameraCaptureSession.StateCallback() {
+ @Override
+ public void onConfigured(CameraCaptureSession captureSession) {
+ session = captureSession;
+ try {
+ builder.set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO);
+ captureSession.setRepeatingRequest(builder.build(), null, handler);
+ Log.i(TAG, "Camera preview running");
+ } catch (CameraAccessException e) {
+ Log.e(TAG, "Repeating request failed", e);
+ }
+ }
+
+ @Override
+ public void onConfigureFailed(CameraCaptureSession captureSession) {
+ Log.e(TAG, "Camera session configure failed");
+ }
+ }, handler);
+ }
+
+ private static String chooseCameraId(CameraManager mgr) throws CameraAccessException {
+ for (String id : mgr.getCameraIdList()) {
+ CameraCharacteristics chars = mgr.getCameraCharacteristics(id);
+ Integer facing = chars.get(CameraCharacteristics.LENS_FACING);
+ if (facing != null && facing == CameraCharacteristics.LENS_FACING_BACK) {
+ return id;
+ }
+ }
+ String[] ids = mgr.getCameraIdList();
+ return ids.length > 0 ? ids[0] : null;
+ }
+
+ private static Size choosePreviewSize(CameraManager mgr, String id, int targetW, int targetH)
+ throws CameraAccessException {
+ CameraCharacteristics chars = mgr.getCameraCharacteristics(id);
+ StreamConfigurationMap map = chars.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);
+ if (map == null) {
+ return new Size(targetW, targetH);
+ }
+ Size[] sizes = map.getOutputSizes(SurfaceTexture.class);
+ if (sizes == null || sizes.length == 0) {
+ return new Size(targetW, targetH);
+ }
+ Size best = sizes[0];
+ long bestScore = Long.MAX_VALUE;
+ for (Size s : sizes) {
+ long score = Math.abs((long) s.getWidth() * s.getHeight() - (long) targetW * targetH);
+ if (score < bestScore) {
+ bestScore = score;
+ best = s;
+ }
+ }
+ return best;
+ }
+
+ public void stop() {
+ if (session != null) {
+ try {
+ session.close();
+ } catch (Exception ignored) {
+ }
+ session = null;
+ }
+ if (camera != null) {
+ try {
+ camera.close();
+ } catch (Exception ignored) {
+ }
+ camera = null;
+ }
+ if (thread != null) {
+ thread.quitSafely();
+ thread = null;
+ }
+ handler = null;
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java
new file mode 100644
index 0000000..a9f2ac4
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java
@@ -0,0 +1,204 @@
+package com.foxx.androidcast.stats;
+
+import android.content.Context;
+
+import com.foxx.androidcast.CastSettings;
+import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
+import com.foxx.androidcast.network.transport.TransportLossStats;
+
+import org.json.JSONObject;
+
+/**
+ * Collects anonymous per-session aggregates; persisted via {@link SessionStatsStore}.
+ */
+public final class SessionStatsRecorder {
+ private final long startedMs = System.currentTimeMillis();
+ private final String role;
+ private final String transport;
+ private String videoCodec = "";
+ private String audioCodec = "AAC";
+ private String captureMode = "";
+ private String resolution = "";
+ private int clientCount = 1;
+ private int disruptCount;
+ private int idleTimeouts;
+ private int networkSamples;
+
+ private long sumRttMs;
+ private long sumLossRatioMicro; // loss * 1e6
+ private long sumVideoBitrateKbps;
+ private long sumAudioBitrateKbps;
+ private long sumEncodeFpsMicro;
+ private long sumDecodeFpsMicro;
+ private int bitrateSamples;
+ private int fpsSamples;
+
+ private final TransportLossStats lossTotals = new TransportLossStats();
+
+ public SessionStatsRecorder(String role, String transport) {
+ this.role = role;
+ this.transport = transport != null ? transport : "unknown";
+ }
+
+ public void setClientCount(int count) {
+ if (count > 0) {
+ clientCount = count;
+ }
+ }
+
+ public void setCodecs(String videoMime, String audioMime) {
+ if (videoMime != null) {
+ videoCodec = videoMime;
+ }
+ if (audioMime != null) {
+ audioCodec = audioMime;
+ }
+ }
+
+ public void setCaptureMode(String mode) {
+ captureMode = mode != null ? mode : "";
+ }
+
+ public void setResolution(int w, int h) {
+ if (w > 0 && h > 0) {
+ resolution = w + "x" + h;
+ }
+ }
+
+ public void noteDisrupt(String reason) {
+ disruptCount++;
+ }
+
+ public void noteIdleTimeout() {
+ idleTimeouts++;
+ disruptCount++;
+ }
+
+ public void sampleNetwork(NetworkStatsSnapshot local, NetworkStatsSnapshot peer) {
+ networkSamples++;
+ NetworkStatsSnapshot s = local != null ? local : peer;
+ if (s == null) {
+ return;
+ }
+ if (s.rttMs > 0) {
+ sumRttMs += s.rttMs;
+ }
+ if (s.lossRatio > 0) {
+ sumLossRatioMicro += (long) (s.lossRatio * 1_000_000);
+ }
+ if (s.sendBitrateKbps > 0) {
+ sumVideoBitrateKbps += s.sendBitrateKbps;
+ bitrateSamples++;
+ } else if (s.recvBitrateKbps > 0) {
+ sumVideoBitrateKbps += s.recvBitrateKbps;
+ bitrateSamples++;
+ }
+ if (s.encodeFps > 0) {
+ sumEncodeFpsMicro += (long) (s.encodeFps * 1000);
+ fpsSamples++;
+ }
+ if (s.targetBitrateKbps > 0) {
+ sumAudioBitrateKbps += Math.min(s.targetBitrateKbps, 256);
+ }
+ }
+
+ /** Final transport/app loss snapshot for this session (not accumulated over time). */
+ public void mergeLoss(TransportLossStats loss) {
+ if (loss == null) {
+ return;
+ }
+ synchronized (lossTotals) {
+ lossTotals.datagramsSent = loss.datagramsSent;
+ lossTotals.datagramsReceived = loss.datagramsReceived;
+ lossTotals.datagramsInvalid = loss.datagramsInvalid;
+ lossTotals.fragmentsReceived = loss.fragmentsReceived;
+ lossTotals.fragmentsDuplicate = loss.fragmentsDuplicate;
+ lossTotals.reassemblyCompleted = loss.reassemblyCompleted;
+ lossTotals.reassemblyIncomplete = loss.reassemblyIncomplete;
+ lossTotals.reassemblySuperseded = loss.reassemblySuperseded;
+ lossTotals.wirePacketsDropped = loss.wirePacketsDropped;
+ lossTotals.malformedPackets = loss.malformedPackets;
+ lossTotals.sendDroppedVideoP = loss.sendDroppedVideoP;
+ lossTotals.sendDroppedAudio = loss.sendDroppedAudio;
+ lossTotals.recvVideoSkippedAwaitingKey = loss.recvVideoSkippedAwaitingKey;
+ lossTotals.recvVideoDecodeErrors = loss.recvVideoDecodeErrors;
+ lossTotals.recvAudioDecodeErrors = loss.recvAudioDecodeErrors;
+ lossTotals.recvAudioQueueDrops = loss.recvAudioQueueDrops;
+ lossTotals.protectionMode = loss.protectionMode;
+ }
+ }
+
+ public void mergeSettings(CastSettings settings) {
+ if (settings == null) {
+ return;
+ }
+ captureMode = settings.getCaptureMode() != null ? settings.getCaptureMode().name() : captureMode;
+ if (settings.getNegotiatedVideoMime() != null) {
+ videoCodec = settings.getNegotiatedVideoMime();
+ }
+ }
+
+ public void finish(Context context) {
+ long endedMs = System.currentTimeMillis();
+ try {
+ JSONObject root = new JSONObject();
+ root.put("schema_version", 1);
+ root.put("started_at_epoch_ms", startedMs);
+ root.put("ended_at_epoch_ms", endedMs);
+ root.put("duration_ms", endedMs - startedMs);
+ root.put("role", role);
+ root.put("transport", transport);
+ root.put("client_count", clientCount);
+ root.put("disrupt_count", disruptCount);
+ root.put("idle_timeout_count", idleTimeouts);
+ root.put("video_codec", videoCodec);
+ root.put("audio_codec", audioCodec);
+ root.put("capture_mode", captureMode);
+ root.put("resolution", resolution);
+ root.put("network_samples", networkSamples);
+
+ if (networkSamples > 0) {
+ root.put("avg_rtt_ms", sumRttMs / Math.max(1, networkSamples));
+ root.put("avg_loss_ratio", sumLossRatioMicro / (1_000_000.0 * Math.max(1, networkSamples)));
+ }
+ if (bitrateSamples > 0) {
+ root.put("avg_video_bitrate_kbps", sumVideoBitrateKbps / bitrateSamples);
+ }
+ if (fpsSamples > 0) {
+ root.put("avg_encode_fps", sumEncodeFpsMicro / (1000.0 * fpsSamples));
+ }
+
+ JSONObject protection = new JSONObject();
+ protection.put("mode", lossTotals.protectionMode.name());
+ protection.put("fec", "stub_disabled");
+ protection.put("nack", "stub_disabled");
+ protection.put("fec_stub_packets", lossTotals.fecPacketsStub);
+ protection.put("nack_stub_requests", lossTotals.nackRequestsStub);
+ root.put("stream_protection", protection);
+
+ JSONObject udp = new JSONObject();
+ synchronized (lossTotals) {
+ udp.put("datagrams_tx", lossTotals.datagramsSent);
+ udp.put("datagrams_rx", lossTotals.datagramsReceived);
+ udp.put("datagrams_invalid", lossTotals.datagramsInvalid);
+ udp.put("fragments_rx", lossTotals.fragmentsReceived);
+ udp.put("fragments_duplicate", lossTotals.fragmentsDuplicate);
+ udp.put("reassembly_ok", lossTotals.reassemblyCompleted);
+ udp.put("reassembly_lost", lossTotals.reassemblyIncomplete + lossTotals.reassemblySuperseded);
+ udp.put("wire_dropped", lossTotals.wirePacketsDropped);
+ udp.put("malformed", lossTotals.malformedPackets);
+ udp.put("send_dropped_video_p", lossTotals.sendDroppedVideoP);
+ udp.put("send_dropped_audio", lossTotals.sendDroppedAudio);
+ udp.put("recv_video_skipped_awaiting_key", lossTotals.recvVideoSkippedAwaitingKey);
+ udp.put("recv_video_decode_errors", lossTotals.recvVideoDecodeErrors);
+ udp.put("recv_audio_decode_errors", lossTotals.recvAudioDecodeErrors);
+ udp.put("recv_audio_queue_drops", lossTotals.recvAudioQueueDrops);
+ udp.put("recv_damaged_video_frames", lossTotals.recvDamagedVideoFrames);
+ }
+ root.put("udp", udp);
+
+ SessionStatsStore.saveSession(context, startedMs, root);
+ } catch (Exception ignored) {
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsStore.java b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsStore.java
new file mode 100644
index 0000000..c0a4f24
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsStore.java
@@ -0,0 +1,73 @@
+package com.foxx.androidcast.stats;
+
+import android.content.Context;
+
+import com.foxx.androidcast.CastConfig;
+
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+
+/** Rotating flat JSON session logs in app-private storage. */
+public final class SessionStatsStore {
+ private static final String DIR_NAME = "session_stats";
+ private static final String PREFIX = "session_";
+ private static final String SUFFIX = ".json";
+
+ private SessionStatsStore() {}
+
+ public static File statsDir(Context context) {
+ return new File(context.getApplicationContext().getFilesDir(), DIR_NAME);
+ }
+
+ /** Keep at most {@link CastConfig#SESSION_STATS_MAX_FILES} - 1 files before a new session. */
+ public static void pruneOnAppStart(Context context) {
+ pruneDirectory(statsDir(context), CastConfig.SESSION_STATS_MAX_FILES);
+ }
+
+ /** Deletes oldest session JSON files until count is below {@code maxFiles}. */
+ static void pruneDirectory(File dir, int maxFiles) {
+ if (dir == null || !dir.exists()) {
+ return;
+ }
+ File[] files = dir.listFiles((d, name) -> name.startsWith(PREFIX) && name.endsWith(SUFFIX));
+ if (files == null || files.length == 0) {
+ return;
+ }
+ List list = new ArrayList<>();
+ Collections.addAll(list, files);
+ Collections.sort(list, Comparator.comparing(File::getName));
+ while (list.size() >= maxFiles) {
+ File oldest = list.remove(0);
+ if (!oldest.delete()) {
+ break;
+ }
+ }
+ }
+
+ public static void saveSession(Context context, long startedMs, JSONObject json) throws Exception {
+ File dir = statsDir(context);
+ if (!dir.exists() && !dir.mkdirs()) {
+ throw new IllegalStateException("Cannot create session_stats dir");
+ }
+ String name = PREFIX + formatTimestamp(startedMs) + SUFFIX;
+ File out = new File(dir, name);
+ byte[] bytes = json.toString(2).getBytes(StandardCharsets.UTF_8);
+ try (FileOutputStream fos = new FileOutputStream(out)) {
+ fos.write(bytes);
+ }
+ }
+
+ private static String formatTimestamp(long epochMs) {
+ return new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date(epochMs));
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/ui/AspectRatioFrameLayout.java b/app/src/main/java/com/foxx/androidcast/ui/AspectRatioFrameLayout.java
index 93f48d7..bf3f6c5 100644
--- a/app/src/main/java/com/foxx/androidcast/ui/AspectRatioFrameLayout.java
+++ b/app/src/main/java/com/foxx/androidcast/ui/AspectRatioFrameLayout.java
@@ -29,12 +29,19 @@ public class AspectRatioFrameLayout extends FrameLayout {
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
- super.onMeasure(widthMeasureSpec, heightMeasureSpec);
- if (aspectRatio <= 0f || getChildCount() == 0) {
- return;
- }
int parentW = MeasureSpec.getSize(widthMeasureSpec);
int parentH = MeasureSpec.getSize(heightMeasureSpec);
+ if (getChildCount() == 0) {
+ setMeasuredDimension(parentW, parentH);
+ return;
+ }
+ if (aspectRatio <= 0f) {
+ int fullW = MeasureSpec.makeMeasureSpec(parentW, MeasureSpec.EXACTLY);
+ int fullH = MeasureSpec.makeMeasureSpec(parentH, MeasureSpec.EXACTLY);
+ measureChild(getChildAt(0), fullW, fullH);
+ setMeasuredDimension(parentW, parentH);
+ return;
+ }
int childW;
int childH;
float parentRatio = (float) parentW / parentH;
@@ -56,6 +63,10 @@ public class AspectRatioFrameLayout extends FrameLayout {
if (getChildCount() == 0) {
return;
}
+ if (aspectRatio <= 0f) {
+ getChildAt(0).layout(left, top, right, bottom);
+ return;
+ }
int parentW = right - left;
int parentH = bottom - top;
int childW = getChildAt(0).getMeasuredWidth();
diff --git a/app/src/main/java/com/foxx/androidcast/ui/PlaybackTextureTransform.java b/app/src/main/java/com/foxx/androidcast/ui/PlaybackTextureTransform.java
new file mode 100644
index 0000000..80b05f6
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/ui/PlaybackTextureTransform.java
@@ -0,0 +1,41 @@
+package com.foxx.androidcast.ui;
+
+import android.graphics.Matrix;
+import android.view.TextureView;
+
+/**
+ * Letterboxes decoded video in a {@link TextureView}.
+ * Uses view-space scale around center (same approach as ExoPlayer), not pixel {@link Matrix#setRectToRect}.
+ */
+public final class PlaybackTextureTransform {
+ private PlaybackTextureTransform() {}
+
+ public static void applyFitCenter(TextureView view, int videoWidth, int videoHeight) {
+ if (view == null || videoWidth <= 0 || videoHeight <= 0) {
+ return;
+ }
+ int viewW = view.getWidth();
+ int viewH = view.getHeight();
+ if (viewW <= 0 || viewH <= 0) {
+ return;
+ }
+ float videoAspect = (float) videoWidth / videoHeight;
+ float viewAspect = (float) viewW / viewH;
+ float scaleX = 1f;
+ float scaleY = 1f;
+ if (videoAspect > viewAspect) {
+ scaleY = viewAspect / videoAspect;
+ } else {
+ scaleX = videoAspect / viewAspect;
+ }
+ Matrix matrix = new Matrix();
+ matrix.setScale(scaleX, scaleY, viewW / 2f, viewH / 2f);
+ view.setTransform(matrix);
+ }
+
+ public static void clearTransform(TextureView view) {
+ if (view != null) {
+ view.setTransform(new Matrix());
+ }
+ }
+}
diff --git a/app/src/main/res/drawable-nodpi/calibration_chart_reference.png b/app/src/main/res/drawable-nodpi/calibration_chart_reference.png
new file mode 100644
index 0000000..6d0fa7b
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/calibration_chart_reference.png differ
diff --git a/app/src/main/res/layout/activity_playback.xml b/app/src/main/res/layout/activity_playback.xml
index 6186e97..38ce9eb 100644
--- a/app/src/main/res/layout/activity_playback.xml
+++ b/app/src/main/res/layout/activity_playback.xml
@@ -16,7 +16,7 @@
android:layout_height="match_parent"
android:layout_gravity="center">
- Android Cast