1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 04:38:53 +03:00

before UX

This commit is contained in:
Anton Afanasyeu
2026-05-17 15:45:19 +02:00
parent 0a6a4269f4
commit 6b4cd9fc02
22 changed files with 815 additions and 355 deletions

View File

@@ -47,6 +47,12 @@ public final class CastConfig {
public static final int AUDIO_CHANNEL_COUNT = 2;
public static final int AUDIO_BITRATE = 96_000;
/**
* Optional cap on calibration chart long edge (0 = use asset size as-is).
* Keeps encoder load bounded if the reference PNG is replaced with a larger image.
*/
public static final int CALIBRATION_MAX_LONG_EDGE = 0;
/** Max UDP payload chunk (fits typical WiFi MTU with header). */
public static final int UDP_CHUNK_SIZE = 1200;

View File

@@ -1,8 +1,10 @@
package com.foxx.androidcast;
import android.content.Context;
import android.util.DisplayMetrics;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.sender.calibration.CalibrationSourceDimensions;
/** Computes encoder VirtualDisplay size from display metrics and user settings. */
public final class CastResolution {
@@ -71,8 +73,8 @@ public final class CastResolution {
return Math.max(2, v & ~1);
}
/** Fixed 16:9 landscape chart size for calibration test mode (receiver letterboxing). */
public static Size calibrationTestSize() {
return new Size(960, 540, 240);
/** Calibration encoder size from chart asset intrinsic dimensions. */
public static Size calibrationTestSize(Context context) {
return CalibrationSourceDimensions.resolve(context);
}
}

View File

@@ -100,6 +100,7 @@ public class DiscoveryManager {
if (!running.compareAndSet(false, true)) {
return;
}
purgeSelfFromDeviceMap();
notifyScanStarted();
executor.execute(this::browseLoop);
}
@@ -355,6 +356,14 @@ public class DiscoveryManager {
return;
}
String host = packet.getAddress().getHostAddress();
String remoteUuid = json.optString("device_uuid", "");
if (DiscoverySelfFilter.isSelf(appContext, host, remoteUuid)) {
String key = host + ":" + port;
if (devices.remove(key) != null) {
notifyListener();
}
return;
}
String key = host + ":" + port;
DiscoveredDevice device = new DiscoveredDevice(name, host, port, transport,
System.currentTimeMillis(), videoCodecs, audioCodecs, placeholderId, protoVersion);
@@ -374,6 +383,20 @@ public class DiscoveryManager {
| ((b[off + 2] & 0xff) << 8) | (b[off + 3] & 0xff);
}
private void purgeSelfFromDeviceMap() {
boolean changed = false;
for (String key : new ArrayList<>(devices.keySet())) {
DiscoveredDevice d = devices.get(key);
if (d != null && DiscoverySelfFilter.isSelf(appContext, d.host, null)) {
devices.remove(key);
changed = true;
}
}
if (changed) {
notifyListener();
}
}
private void notifyListener() {
if (listener == null) {
return;
@@ -381,7 +404,8 @@ public class DiscoveryManager {
long now = System.currentTimeMillis();
List<DiscoveredDevice> fresh = new ArrayList<>();
for (DiscoveredDevice d : devices.values()) {
if (now - d.lastSeenMs < 10_000) {
if (now - d.lastSeenMs < 10_000
&& !DiscoverySelfFilter.isSelf(appContext, d.host, null)) {
fresh.add(d);
}
}

View File

@@ -0,0 +1,67 @@
package com.foxx.androidcast.discovery;
import android.content.Context;
import com.foxx.androidcast.DeviceInfo;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.Set;
/** Drops LAN discovery entries that refer to this device. */
final class DiscoverySelfFilter {
private DiscoverySelfFilter() {}
static boolean isSelf(Context context, String host, String remoteDeviceUuid) {
if (context != null && remoteDeviceUuid != null && !remoteDeviceUuid.isEmpty()) {
if (remoteDeviceUuid.equals(DeviceInfo.getDeviceUuid(context.getApplicationContext()))) {
return true;
}
}
return isLocalHost(host);
}
static boolean isLocalHost(String host) {
if (host == null || host.isEmpty()) {
return false;
}
if ("127.0.0.1".equals(host) || "::1".equals(host) || "0:0:0:0:0:0:0:1".equals(host)) {
return true;
}
for (String local : localIpv4Addresses()) {
if (local.equals(host)) {
return true;
}
}
return false;
}
private static Set<String> localIpv4Addresses() {
Set<String> out = new HashSet<>();
try {
Enumeration<NetworkInterface> ifaces = NetworkInterface.getNetworkInterfaces();
if (ifaces == null) {
return out;
}
for (NetworkInterface ni : Collections.list(ifaces)) {
if (!ni.isUp() || ni.isLoopback()) {
continue;
}
for (InetAddress addr : Collections.list(ni.getInetAddresses())) {
if (addr instanceof Inet4Address && !addr.isLoopbackAddress()) {
String h = addr.getHostAddress();
if (h != null) {
out.add(h);
}
}
}
}
} catch (Exception ignored) {
}
return out;
}
}

View File

@@ -17,14 +17,14 @@ import com.foxx.androidcast.media.PcmLevelAnalyzer;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
/** AAC decoder → AudioTrack; input and output run on separate threads. */
/** AAC decoder → AudioTrack; low-latency live playback (no sender-PTS sleep). */
public class AudioDecoder {
private static final String TAG = "AudioDecoder";
private static final String MIME = MediaFormat.MIMETYPE_AUDIO_AAC;
private static final int MAX_INPUT_QUEUE = 48;
public interface RenderCallback {
void onPcmRendered(int bytes, PcmLevelAnalyzer.Levels levels);
@@ -38,6 +38,7 @@ public class AudioDecoder {
public long pcmAudibleBlocks;
public long decodeErrors;
public long queueDrops;
public long writeShortfalls;
public float lastPeak;
public float lastRms;
}
@@ -45,8 +46,7 @@ public class AudioDecoder {
private final Context appContext;
private final HandlerThread workerThread = new HandlerThread("AudioDecoderCtrl");
private Handler worker;
private final LinkedBlockingQueue<PendingFrame> inputQueue = new LinkedBlockingQueue<>(256);
private long playbackEpochUs = -1;
private final LinkedBlockingQueue<PendingFrame> inputQueue = new LinkedBlockingQueue<>(MAX_INPUT_QUEUE);
private final Stats stats = new Stats();
private MediaCodec codec;
@@ -58,6 +58,8 @@ public class AudioDecoder {
private int channels = 2;
private boolean useAdtsMode;
private RenderCallback renderCallback;
private PendingFrame retryFrame;
private long lastDropLogMs;
private static final class PendingFrame {
final long ptsUs;
@@ -105,10 +107,12 @@ public class AudioDecoder {
createAudioTrack(sampleRate, channels);
configured = true;
draining = true;
retryFrame = null;
startDrainThread();
Log.i(TAG, "Audio ready " + sampleRate + "Hz ch=" + channels
+ " mode=" + (useAdtsMode ? "ADTS" : "raw+csd")
+ " csdLen=" + (csd0 != null ? csd0.length : 0));
+ " csdLen=" + (csd0 != null ? csd0.length : 0)
+ " qMax=" + MAX_INPUT_QUEUE);
} catch (IOException e) {
error[0] = e;
} catch (Exception e) {
@@ -133,7 +137,7 @@ public class AudioDecoder {
private void startDrainThread() {
drainThread = new Thread(this::drainLoop, "AudioDecoderDrain");
drainThread.setPriority(Thread.MAX_PRIORITY - 2);
drainThread.setPriority(Thread.MAX_PRIORITY - 1);
drainThread.start();
}
@@ -142,7 +146,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 * 16, 131072);
int bufSize = Math.max(minBuf * 4, 16384);
track = new AudioTrack.Builder()
.setAudioAttributes(new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
@@ -159,7 +163,7 @@ public class AudioDecoder {
track.setVolume(1.0f);
requestAudioFocus();
track.play();
Log.i(TAG, "AudioTrack play state=" + track.getPlayState());
Log.i(TAG, "AudioTrack buf=" + bufSize + " playState=" + track.getPlayState());
}
public void queueFrame(long ptsUs, byte[] data) {
@@ -168,18 +172,33 @@ public class AudioDecoder {
}
stats.framesQueued++;
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
stats.queueDrops++;
inputQueue.poll();
PendingFrame dropped = inputQueue.poll();
if (dropped != null) {
stats.queueDrops++;
logDropBurst();
}
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
stats.queueDrops++;
logDropBurst();
}
}
}
private void logDropBurst() {
long now = System.currentTimeMillis();
if (now - lastDropLogMs > 2000) {
lastDropLogMs = now;
Log.w(TAG, "AAC input drops=" + stats.queueDrops + " q=" + inputQueue.size());
}
}
private void drainLoop() {
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
while (draining) {
try {
if (retryFrame != null) {
feedFrameSync(retryFrame, info);
}
PendingFrame frame = inputQueue.poll(5, TimeUnit.MILLISECONDS);
if (frame != null && codec != null) {
feedFrameSync(frame, info);
@@ -198,16 +217,19 @@ public class AudioDecoder {
}
private void feedFrameSync(PendingFrame frame, MediaCodec.BufferInfo info) {
retryFrame = null;
byte[] data = frame.data;
if (useAdtsMode) {
data = AacAdtsHelper.wrapIfNeeded(data, sampleRate, channels);
}
int inIndex = codec.dequeueInputBuffer(10_000);
int inIndex = codec.dequeueInputBuffer(5_000);
if (inIndex < 0) {
retryFrame = frame;
return;
}
ByteBuffer buffer = codec.getInputBuffer(inIndex);
if (buffer == null) {
retryFrame = frame;
return;
}
buffer.clear();
@@ -248,41 +270,36 @@ public class AudioDecoder {
} else {
stats.pcmAudibleBlocks++;
}
pacePlayback(info.presentationTimeUs);
int written = writePcmBlocking(pcm);
int written = writePcm(pcm);
if (written > 0) {
stats.pcmBytesWritten += written;
if (renderCallback != null) {
renderCallback.onPcmRendered(written, levels);
}
} else if (written < pcm.length) {
stats.writeShortfalls++;
}
}
codec.releaseOutputBuffer(outIndex, false);
}
}
private void pacePlayback(long ptsUs) {
long nowUs = System.nanoTime() / 1000L;
if (playbackEpochUs < 0) {
playbackEpochUs = nowUs - ptsUs;
return;
private int writePcm(byte[] pcm) {
if (track == null || pcm.length == 0) {
return 0;
}
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();
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
int w = track.write(pcm, 0, pcm.length, AudioTrack.WRITE_BLOCKING);
return Math.max(0, w);
}
return writePcmLegacy(pcm);
}
private int writePcmBlocking(byte[] pcm) {
private int writePcmLegacy(byte[] pcm) {
int total = 0;
int offset = 0;
int idle = 0;
while (offset < pcm.length && idle < 80) {
while (offset < pcm.length && idle < 40) {
int w = track.write(pcm, offset, pcm.length - offset);
if (w > 0) {
total += w;
@@ -292,7 +309,7 @@ public class AudioDecoder {
}
idle++;
try {
Thread.sleep(2);
Thread.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
@@ -309,11 +326,11 @@ public class AudioDecoder {
}
}
/** Stops playback immediately and drops queued AAC (sender ended cast). */
public void flushAndStop() {
draining = false;
configured = false;
inputQueue.clear();
retryFrame = null;
final Object lock = new Object();
worker.post(() -> {
releaseOnWorker();
@@ -337,7 +354,7 @@ public class AudioDecoder {
private void releaseOnWorker() {
draining = false;
configured = false;
playbackEpochUs = -1;
retryFrame = null;
inputQueue.clear();
if (drainThread != null) {
drainThread.interrupt();
@@ -360,8 +377,6 @@ public class AudioDecoder {
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();

View File

@@ -5,6 +5,7 @@ import android.app.Service;
import android.content.Intent;
import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.Looper;
import android.os.PowerManager;
@@ -28,6 +29,7 @@ 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 com.foxx.androidcast.util.CastBackgroundExecutor;
import java.util.Arrays;
@@ -48,6 +50,9 @@ public class ReceiverCastService extends Service {
private final RemoteCallbackList<ICastStatusCallback> callbacks = new RemoteCallbackList<>();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private HandlerThread videoDecodeThread;
private Handler videoDecodeHandler;
private final Object audioDecoderLock = new Object();
private DiscoveryManager discovery;
private ReceiverSession session;
@@ -60,6 +65,9 @@ public class ReceiverCastService extends Service {
private int pendingWidth;
private int pendingHeight;
/** Stream config size used for letterboxing (not decoder padding). */
private int displayWidth;
private int displayHeight;
private byte[] pendingCsd0;
private byte[] pendingCsd1;
private boolean hasPendingConfig;
@@ -144,6 +152,9 @@ public class ReceiverCastService extends Service {
super.onCreate();
CastNotifications.createChannels(this);
SessionStatsStore.pruneOnAppStart(this);
videoDecodeThread = new HandlerThread("VideoDecode");
videoDecodeThread.start();
videoDecodeHandler = new Handler(videoDecodeThread.getLooper());
mainHandler.post(idleWatchdog);
}
@@ -197,12 +208,16 @@ public class ReceiverCastService extends Service {
videoDecoder.release();
}
if (audioDecoder != null) {
audioDecoder.release();
synchronized (audioDecoderLock) {
audioDecoder.release();
}
}
videoDecoder = new VideoDecoder();
audioDecoder = new AudioDecoder(this);
audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered(
pcmBytes, levels.peak, levels.rms, levels.silent));
synchronized (audioDecoderLock) {
audioDecoder = new AudioDecoder(this);
audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered(
pcmBytes, levels.peak, levels.rms, levels.silent));
}
session = new ReceiverSession(this, pin, settings, new ReceiverSession.Listener() {
@Override
@@ -243,6 +258,7 @@ public class ReceiverCastService extends Service {
reconfiguring = false;
}
final int frameBytes = data != null ? data.length : 0;
final byte[] frameData = data;
mainHandler.post(() -> {
lastVideoFrameMs = System.currentTimeMillis();
if (streamIdle) {
@@ -252,10 +268,14 @@ public class ReceiverCastService extends Service {
synchronized (transportLoss) {
transportLoss.recvVideoPackets++;
}
if (videoDecoder != null) {
videoDecoder.queueFrame(ptsUs, keyFrame, data);
}
});
if (videoDecodeHandler != null) {
videoDecodeHandler.post(() -> {
if (videoDecoder != null) {
videoDecoder.queueFrame(ptsUs, keyFrame, frameData);
}
});
}
}
@Override
@@ -274,11 +294,15 @@ public class ReceiverCastService extends Service {
}
}
final boolean accepted = accept;
if (accepted && audioDecoder != null) {
try {
audioDecoder.configure(sampleRate, channels, csd0);
} catch (Exception e) {
Log.e(TAG, "Audio configure on config msg failed", e);
if (accepted) {
synchronized (audioDecoderLock) {
if (audioDecoder != null) {
try {
audioDecoder.configure(sampleRate, channels, csd0);
} catch (Exception e) {
Log.e(TAG, "Audio configure on config msg failed", e);
}
}
}
}
mainHandler.post(() -> onAudioConfigReceived(sampleRate, channels, csd0, accepted));
@@ -294,8 +318,12 @@ public class ReceiverCastService extends Service {
synchronized (ReceiverCastService.this) {
accept = Boolean.TRUE.equals(audioAccepted);
}
if (accept && audioDecoder != null) {
audioDecoder.queueFrame(ptsUs, data);
if (accept) {
synchronized (audioDecoderLock) {
if (audioDecoder != null) {
audioDecoder.queueFrame(ptsUs, data);
}
}
}
mainHandler.post(() -> {
streamMetrics.onAudioPacket(frameBytes);
@@ -360,20 +388,22 @@ public class ReceiverCastService extends Service {
}
private void sendAudioConsentAsync(final boolean accept) {
new Thread(() -> {
CastBackgroundExecutor.execute(() -> {
if (session != null) {
session.sendAudioConsent(accept);
}
}, "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));
synchronized (audioDecoderLock) {
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");
}
@@ -388,6 +418,8 @@ public class ReceiverCastService extends Service {
negotiatedVideoMime = null;
pendingWidth = 0;
pendingHeight = 0;
displayWidth = 0;
displayHeight = 0;
hasPendingConfig = false;
resetDecoderState();
enterAwaitingMode(R.string.playback_listening, true);
@@ -442,6 +474,8 @@ public class ReceiverCastService extends Service {
awaitingKeyframe = true;
pendingWidth = 0;
pendingHeight = 0;
displayWidth = 0;
displayHeight = 0;
pendingCsd0 = null;
pendingCsd1 = null;
resetDecoderState();
@@ -460,11 +494,13 @@ public class ReceiverCastService extends Service {
if (accepted) {
updateStatus(getString(R.string.audio_playing));
} else {
if (audioDecoder != null) {
audioDecoder.release();
audioDecoder = new AudioDecoder(this);
audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered(
pcmBytes, levels.peak, levels.rms, levels.silent));
synchronized (audioDecoderLock) {
if (audioDecoder != null) {
audioDecoder.release();
audioDecoder = new AudioDecoder(this);
audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered(
pcmBytes, levels.peak, levels.rms, levels.silent));
}
}
updateStatus(getString(R.string.audio_skipped));
}
@@ -498,11 +534,13 @@ public class ReceiverCastService extends Service {
openPlaybackAwaiting(R.string.playback_awaiting_stream);
pendingWidth = size.width;
pendingHeight = size.height;
displayWidth = size.width;
displayHeight = size.height;
pendingCsd0 = csd0;
pendingCsd1 = csd1;
hasPendingConfig = true;
decoderActive = false;
notifyPlaybackDimensions(pendingWidth, pendingHeight);
notifyPlaybackDimensions(displayWidth, displayHeight);
scheduleDecoderConfigureRetries();
if (sessionStatsRecorder != null) {
sessionStatsRecorder.setResolution(pendingWidth, pendingHeight);
@@ -664,12 +702,10 @@ public class ReceiverCastService extends Service {
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);
Log.i(TAG, "Decoder output size " + width + "x" + height
+ " (display " + displayWidth + "x" + displayHeight + ")");
});
}
@@ -740,7 +776,7 @@ public class ReceiverCastService extends Service {
}
recorder.setResolution(pendingWidth, pendingHeight);
recorder.mergeLoss(buildReceiverLossSnapshot());
new Thread(() -> recorder.finish(getApplicationContext()), "SessionStatsSave").start();
CastBackgroundExecutor.execute(() -> recorder.finish(getApplicationContext()));
}
private void attachSurface(Surface surface) {
@@ -853,9 +889,11 @@ public class ReceiverCastService extends Service {
videoDecoder.release();
videoDecoder = null;
}
if (audioDecoder != null) {
audioDecoder.release();
audioDecoder = null;
synchronized (audioDecoderLock) {
if (audioDecoder != null) {
audioDecoder.release();
audioDecoder = null;
}
}
attachedSurface = null;
}
@@ -876,6 +914,11 @@ public class ReceiverCastService extends Service {
public void onDestroy() {
mainHandler.removeCallbacks(idleWatchdog);
releaseAll();
if (videoDecodeThread != null) {
videoDecodeThread.quitSafely();
videoDecodeThread = null;
videoDecodeHandler = null;
}
callbacks.kill();
super.onDestroy();
}

View File

@@ -22,6 +22,7 @@ import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicBoolean;
/** Server: PIN auth, then forward encoded A/V to decoders. */
@@ -58,7 +59,14 @@ public class ReceiverSession {
private final String pin;
private final CastSettings localSettings;
private final Listener listener;
private final ExecutorService executor = Executors.newCachedThreadPool();
private final ExecutorService executor = Executors.newSingleThreadExecutor(new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "ReceiverIO");
t.setDaemon(true);
return t;
}
});
private final AtomicBoolean running = new AtomicBoolean(false);
private CastSession session;
@@ -143,6 +151,7 @@ public class ReceiverSession {
} else if (session == null) {
session = new CastSession(listenTransport);
}
inboundSessionGate.reset();
networkFeedback = new NetworkFeedbackManager(session, false);
if (networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) {
((PassThroughNetworkControlPlane) networkFeedback.getPlane())

View File

@@ -98,14 +98,34 @@ public class AudioCapture {
int frameBytes = pcmFrameBytes();
byte[] micBuf = new byte[frameBytes];
byte[] copy = new byte[frameBytes];
long frameIndex = 0;
final long frameNs = 20_000_000L;
long captureEpochNs = -1;
long nextFrameNs = System.nanoTime();
while (running) {
if (!readFully(micRecord, micBuf, frameBytes)) {
continue;
}
long now = System.nanoTime();
if (captureEpochNs < 0) {
captureEpochNs = now;
}
if (now < nextFrameNs) {
long sleepMs = (nextFrameNs - now) / 1_000_000L;
if (sleepMs > 0) {
try {
Thread.sleep(sleepMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
} else if (now - nextFrameNs > frameNs * 5) {
nextFrameNs = now;
captureEpochNs = now;
}
nextFrameNs += frameNs;
System.arraycopy(micBuf, 0, copy, 0, frameBytes);
long ptsUs = frameIndex * 20_000L;
frameIndex++;
long ptsUs = (now - captureEpochNs) / 1000L;
if (callback != null) {
callback.onPcm(copy, frameBytes, ptsUs,
com.foxx.androidcast.media.PcmLevelAnalyzer.analyze(copy, frameBytes));
@@ -199,11 +219,32 @@ public class AudioCapture {
byte[] playBuf = new byte[frameBytes];
byte[] micBuf = new byte[frameBytes];
byte[] mixOut = new byte[frameBytes];
long frameIndex = 0;
final long frameNs = 20_000_000L;
long captureEpochNs = -1;
long nextFrameNs = System.nanoTime();
while (running) {
if (!readFully(playbackRecord, playBuf, frameBytes)) {
continue;
}
long now = System.nanoTime();
if (captureEpochNs < 0) {
captureEpochNs = now;
}
if (now < nextFrameNs) {
long sleepMs = (nextFrameNs - now) / 1_000_000L;
if (sleepMs > 0) {
try {
Thread.sleep(sleepMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
} else if (now - nextFrameNs > frameNs * 5) {
nextFrameNs = now;
captureEpochNs = now;
}
nextFrameNs += frameNs;
int mRead = micRecord != null ? micRecord.read(micBuf, 0, frameBytes) : 0;
System.arraycopy(playBuf, 0, mixOut, 0, frameBytes);
if (mRead > 0 && !hasAudibleSamples(playBuf, frameBytes)) {
@@ -216,8 +257,7 @@ public class AudioCapture {
captureMode = mode;
notifyMode(callback, captureMode);
}
long ptsUs = frameIndex * 20_000L;
frameIndex++;
long ptsUs = (now - captureEpochNs) / 1000L;
if (callback != null) {
PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(mixOut, frameBytes);
callback.onPcm(mixOut, frameBytes, ptsUs, levels);

View File

@@ -21,11 +21,12 @@ public class AudioEncoder {
void onEncodedFrame(long ptsUs, byte[] data);
}
private final Object inputLock = new Object();
private MediaCodec codec;
private Callback callback;
private boolean running;
private volatile boolean running;
private Thread drainThread;
private long presentationBaseNs = -1;
private long droppedPcmFrames;
public void prepare(Callback callback) throws IOException {
this.callback = callback;
@@ -38,55 +39,65 @@ public class AudioEncoder {
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
codec.start();
running = true;
primeSilenceFrames();
drainThread = new Thread(this::drainLoop, "AudioEncoderDrain");
drainThread.start();
startSilencePrimer();
}
/** Feeds silent PCM so AAC config is emitted even when playback capture is idle. */
private void startSilencePrimer() {
Thread primer = new Thread(() -> {
int frameBytes = CastConfig.AUDIO_SAMPLE_RATE * 2 * 2 / 50;
byte[] silence = new byte[frameBytes];
for (int i = 0; i < 40 && running; i++) {
queuePcm(silence, silence.length, i * 20_000L);
try {
Thread.sleep(20);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}, "AudioSilencePrimer");
primer.setDaemon(true);
primer.start();
/** Minimal silence so AAC emits config before mic PCM (no extra thread). */
private void primeSilenceFrames() {
int frameBytes = CastConfig.AUDIO_SAMPLE_RATE * CastConfig.AUDIO_CHANNEL_COUNT * 2 / 50;
byte[] silence = new byte[frameBytes];
for (int i = 0; i < 3; i++) {
queuePcm(silence, frameBytes, i * 20_000L);
}
}
public long getDroppedPcmFrames() {
return droppedPcmFrames;
}
public void queuePcm(byte[] pcm, int length, long ptsUs) {
if (codec == null || !running) {
if (!running) {
return;
}
try {
int index = codec.dequeueInputBuffer(5_000);
if (index < 0) {
synchronized (inputLock) {
MediaCodec c = codec;
if (c == null) {
return;
}
ByteBuffer buffer = codec.getInputBuffer(index);
if (buffer == null) {
return;
try {
int index = -1;
for (int attempt = 0; attempt < 4 && index < 0; attempt++) {
index = c.dequeueInputBuffer(attempt == 0 ? 0 : 3_000);
}
if (index < 0) {
droppedPcmFrames++;
if (droppedPcmFrames == 1 || droppedPcmFrames % 50 == 0) {
Log.w(TAG, "Dropped PCM frame (encoder input busy), total=" + droppedPcmFrames);
}
return;
}
ByteBuffer buffer = c.getInputBuffer(index);
if (buffer == null) {
return;
}
buffer.clear();
buffer.put(pcm, 0, length);
c.queueInputBuffer(index, 0, length, ptsUs, 0);
} catch (Exception e) {
Log.w(TAG, "queuePcm failed", e);
}
buffer.clear();
buffer.put(pcm, 0, length);
codec.queueInputBuffer(index, 0, length, ptsUs, 0);
} catch (Exception e) {
Log.w(TAG, "queuePcm failed", e);
}
}
public void stop() {
running = false;
MediaCodec c = codec;
codec = null;
MediaCodec c;
synchronized (inputLock) {
c = codec;
codec = null;
}
if (drainThread != null) {
try {
drainThread.join(1000);

View File

@@ -13,118 +13,166 @@ import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/** Fan-out send queue to multiple {@link CastSession}s (1:N cast). */
/**
* Fan-out send queue to multiple {@link CastSession}s (1:N cast).
* Audio and video use separate lanes so video backlog cannot starve audio.
*/
public final class CastFanoutPump {
private static final String TAG = "CastFanoutPump";
private static final int MAX_QUEUE = 48;
private static final String TAG = "CastFanoutPump";
private static final int MAX_VIDEO_QUEUE = 40;
private static final int MAX_AUDIO_QUEUE = 96;
private final LinkedBlockingQueue<CastSendPump.Outgoing> queue = new LinkedBlockingQueue<>(MAX_QUEUE);
private final AtomicBoolean running = new AtomicBoolean(false);
private final List<CastSession> sessions = new ArrayList<>();
private Thread thread;
private Runnable onSendFailed;
private PassThroughNetworkControlPlane statsPlane;
private volatile int droppedPFrames;
private final LinkedBlockingQueue<CastSendPump.Outgoing> videoQueue =
new LinkedBlockingQueue<>(MAX_VIDEO_QUEUE);
private final LinkedBlockingQueue<CastSendPump.Outgoing> audioQueue =
new LinkedBlockingQueue<>(MAX_AUDIO_QUEUE);
private final AtomicBoolean running = new AtomicBoolean(false);
private final List<CastSession> sessions = new ArrayList<>();
private Thread thread;
private Runnable onSendFailed;
private PassThroughNetworkControlPlane statsPlane;
private volatile int droppedPFrames;
private volatile int droppedAudioFrames;
public void setSessions(List<CastSession> list) {
sessions.clear();
if (list != null) {
sessions.addAll(list);
}
}
public int getDroppedPFrames() {
return droppedPFrames;
}
public int getQueueDepth() {
return queue.size();
}
public void start(List<CastSession> sessionList, Runnable onSendFailed,
PassThroughNetworkControlPlane statsPlane) {
setSessions(sessionList);
this.onSendFailed = onSendFailed;
this.statsPlane = statsPlane;
if (!running.compareAndSet(false, true)) {
return;
}
thread = new Thread(this::run, "CastFanoutPump");
thread.start();
}
public void stop() {
running.set(false);
if (thread != null) {
thread.interrupt();
try {
thread.join(1500);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
thread = null;
}
queue.clear();
sessions.clear();
}
public void enqueueVideo(long ptsUs, boolean keyFrame, byte[] annexBReadyPayload) {
if (!running.get()) {
return;
}
CastSendPump.Outgoing item = CastSendPump.Outgoing.video(ptsUs, keyFrame, annexBReadyPayload);
if (!queue.offer(item)) {
if (!keyFrame) {
CastSendPump.Outgoing dropped = queue.poll();
if (dropped != null && !dropped.keyFrame) {
droppedPFrames++;
public void setSessions(List<CastSession> list) {
sessions.clear();
if (list != null) {
sessions.addAll(list);
}
}
queue.offer(item);
}
}
public void enqueue(byte type, byte[] payload) {
if (!running.get()) {
return;
public int getDroppedPFrames() {
return droppedPFrames;
}
CastSendPump.Outgoing item = CastSendPump.Outgoing.raw(type, payload);
while (running.get() && !queue.offer(item)) {
queue.poll();
}
}
private void run() {
while (running.get()) {
try {
CastSendPump.Outgoing item = queue.poll(200, TimeUnit.MILLISECONDS);
if (item == null || sessions.isEmpty()) {
continue;
public int getDroppedAudioFrames() {
return droppedAudioFrames;
}
public int getQueueDepth() {
return videoQueue.size() + audioQueue.size();
}
public void start(List<CastSession> sessionList, Runnable onSendFailed,
PassThroughNetworkControlPlane statsPlane) {
setSessions(sessionList);
this.onSendFailed = onSendFailed;
this.statsPlane = statsPlane;
if (!running.compareAndSet(false, true)) {
return;
}
byte[] payload = item.buildPayload();
thread = new Thread(this::run, "CastFanoutPump");
thread.setPriority(Thread.NORM_PRIORITY + 1);
thread.start();
Log.i(TAG, "Started fan-out to " + sessions.size() + " peer(s)");
}
public void stop() {
running.set(false);
videoQueue.clear();
audioQueue.clear();
if (thread != null) {
thread.interrupt();
try {
thread.join(1500);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
thread = null;
}
sessions.clear();
}
public void enqueueVideo(long ptsUs, boolean keyFrame, byte[] annexBReadyPayload) {
if (!running.get()) {
return;
}
CastSendPump.Outgoing item = CastSendPump.Outgoing.video(ptsUs, keyFrame, annexBReadyPayload);
if (!videoQueue.offer(item)) {
if (!keyFrame) {
dropOneVideoPFrame();
}
if (!videoQueue.offer(item) && !keyFrame) {
droppedPFrames++;
}
}
}
public void enqueue(byte type, byte[] payload) {
if (!running.get()) {
return;
}
CastSendPump.Outgoing item = CastSendPump.Outgoing.raw(type, payload);
boolean audio = type == CastProtocol.MSG_AUDIO_FRAME || type == CastProtocol.MSG_AUDIO_CONFIG;
if (audio) {
if (!audioQueue.offer(item)) {
CastSendPump.Outgoing dropped = audioQueue.poll();
if (dropped != null) {
droppedAudioFrames++;
}
audioQueue.offer(item);
}
return;
}
while (running.get() && !videoQueue.offer(item)) {
dropOneVideoPFrame();
}
}
private void dropOneVideoPFrame() {
for (CastSendPump.Outgoing o : videoQueue) {
if (o.type == CastProtocol.MSG_VIDEO_FRAME && !o.keyFrame) {
videoQueue.remove(o);
droppedPFrames++;
return;
}
}
CastSendPump.Outgoing head = videoQueue.poll();
if (head != null && head.type == CastProtocol.MSG_VIDEO_FRAME && !head.keyFrame) {
droppedPFrames++;
}
}
private void run() {
while (running.get()) {
try {
CastSendPump.Outgoing item = audioQueue.poll();
if (item == null) {
item = videoQueue.poll(50, TimeUnit.MILLISECONDS);
}
if (item == null || sessions.isEmpty()) {
continue;
}
byte[] payload = item.buildPayload();
fanOut(item.type, payload);
} catch (InterruptedException e) {
if (!running.get()) {
break;
}
} catch (IOException e) {
Log.w(TAG, "Payload build failed: " + e.getMessage());
if (onSendFailed != null) {
onSendFailed.run();
}
}
}
}
private void fanOut(byte type, byte[] payload) {
int peer = 0;
for (CastSession session : sessions) {
if (session == null) {
continue;
}
try {
session.send(item.type, payload);
if (statsPlane != null) {
statsPlane.recordSendBytes(payload.length + 5);
peer++;
if (session == null) {
continue;
}
try {
session.send(type, payload);
if (statsPlane != null) {
statsPlane.recordSendBytes(payload.length + 5);
}
} catch (IOException e) {
Log.w(TAG, "Send to peer #" + peer + " failed: " + e.getMessage());
}
} catch (IOException e) {
Log.w(TAG, "Send to peer failed: " + e.getMessage());
}
}
} catch (InterruptedException e) {
if (!running.get()) {
break;
}
} catch (IOException e) {
Log.w(TAG, "Payload build failed: " + e.getMessage());
if (onSendFailed != null) {
onSendFailed.run();
}
}
}
}
}

View File

@@ -13,10 +13,18 @@ import com.foxx.androidcast.CastConfig;
public final class EncoderKeepalive {
private static final String TAG = "EncoderKeepalive";
private final Handler handler = new Handler(Looper.getMainLooper());
private final Handler handler;
private VideoEncoder encoder;
private boolean running;
public EncoderKeepalive() {
this(new Handler(Looper.getMainLooper()));
}
public EncoderKeepalive(Handler handler) {
this.handler = handler != null ? handler : new Handler(Looper.getMainLooper());
}
private final Runnable tick = new Runnable() {
@Override
public void run() {

View File

@@ -139,9 +139,17 @@ public final class MultiCastCoordinator {
}
public CastProtocol.Message pollReceive(int timeoutMs) throws IOException {
if (peers.isEmpty()) {
return null;
}
long deadline = System.currentTimeMillis() + timeoutMs;
CastProtocol.Message any = null;
for (ActivePeer p : peers) {
CastProtocol.Message msg = p.session.receive(timeoutMs);
long remaining = deadline - System.currentTimeMillis();
if (remaining <= 0) {
break;
}
CastProtocol.Message msg = p.session.receive((int) remaining);
if (msg != null) {
any = msg;
}
@@ -149,6 +157,17 @@ public final class MultiCastCoordinator {
return any;
}
/** Sends the same control message to every connected peer (e.g. stream config after pipeline ready). */
public void broadcast(byte type, byte[] payload) {
for (ActivePeer p : peers) {
try {
p.session.send(type, payload);
} catch (IOException e) {
Log.w(TAG, "Broadcast to " + p.target.name + " failed: " + e.getMessage());
}
}
}
public void sendGoodbyeAll() {
for (ActivePeer p : peers) {
try {

View File

@@ -10,6 +10,7 @@ import android.media.projection.MediaProjection;
import android.media.projection.MediaProjectionManager;
import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.Looper;
import android.os.PowerManager;
@@ -68,6 +69,9 @@ public class ScreenCastService extends Service implements
private final RemoteCallbackList<ICastStatusCallback> callbacks = new RemoteCallbackList<>();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private HandlerThread projectionThread;
private Handler projectionHandler;
private final AtomicReference<IOException> captureSetupError = new AtomicReference<>();
private final AtomicBoolean stopping = new AtomicBoolean(false);
private final AtomicBoolean casting = new AtomicBoolean(false);
private final AtomicBoolean resourcesReleased = new AtomicBoolean(false);
@@ -143,6 +147,9 @@ public class ScreenCastService extends Service implements
SessionStatsStore.pruneOnAppStart(this);
CastNotifications.createChannels(this);
com.foxx.androidcast.network.CastTransportFactory.init(this);
projectionThread = new HandlerThread("CastProjection");
projectionThread.start();
projectionHandler = new Handler(projectionThread.getLooper());
}
@Override
@@ -209,11 +216,11 @@ public class ScreenCastService extends Service implements
deviceName != null ? deviceName : host, host, port, settings.getTransport(), 0, 0, 0));
}
multiCoordinator = new MultiCastCoordinator(this);
multiCoordinator.setKeyframeRequester(() -> {
multiCoordinator.setKeyframeRequester(() -> runOnProjectionThread(() -> {
if (videoEncoder != null) {
videoEncoder.requestKeyframe();
}
});
}));
updateStatus(getString(R.string.connecting_to, host));
multiCoordinator.connectAndHandshake(targets, settings,
deviceName != null ? deviceName : "Sender", pin);
@@ -225,6 +232,7 @@ public class ScreenCastService extends Service implements
session = primary.session;
settings = primary.settings;
settings.setNegotiatedVideoMime(primary.negotiatedMime);
final CastSettings captureSettings = settings;
updateStatus(getString(R.string.authenticating));
networkFeedback = new NetworkFeedbackManager(session, true);
PassThroughNetworkControlPlane plane =
@@ -244,15 +252,21 @@ public class ScreenCastService extends Service implements
sessionStatsRecorder.mergeSettings(settings);
sessionStatsRecorder.setClientCount(peers.size());
sessionStatsRecorder.setCodecs(settings.getNegotiatedVideoMime(), "AAC");
final int projResultCode = resultCode;
final Intent projData = data != null ? new Intent(data) : null;
if (calibration) {
startCalibrationCaptureOnMainThread(settings);
postCaptureSetup(() -> startCalibrationCaptureInternal(captureSettings));
} else if (camera) {
startCameraCaptureOnMainThread(settings);
postCaptureSetup(() -> startCameraCaptureInternal(captureSettings));
} else {
startCaptureOnMainThread(resultCode, new Intent(data), settings);
postCaptureSetup(() -> startCaptureInternal(projResultCode, projData, captureSettings));
}
scheduleTuningTick();
while (!stopping.get()) {
IOException setupErr = captureSetupError.get();
if (setupErr != null) {
throw setupErr;
}
try {
if (networkFeedback != null) {
networkFeedback.tick();
@@ -325,64 +339,38 @@ public class ScreenCastService extends Service implements
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<IOException> 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();
}
});
private void postCaptureSetup(CaptureSetupTask task) {
captureSetupError.set(null);
Handler handler = projectionHandler != null ? projectionHandler : mainHandler;
handler.post(() -> {
try {
if (!latch.await(15, TimeUnit.SECONDS)) {
throw new IOException("Calibration setup timeout");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted", e);
task.run();
} catch (IOException e) {
Log.e(TAG, "Capture setup failed", e);
captureSetupError.set(e);
stopping.set(true);
} catch (Exception e) {
Log.e(TAG, "Capture setup failed", e);
captureSetupError.set(new IOException(
e.getMessage() != null ? e.getMessage() : e.toString(), e));
stopping.set(true);
}
if (error.get() != null) {
throw error.get();
}
return;
}
CastResolution.Size size = CastResolution.calibrationTestSize();
});
}
@FunctionalInterface
private interface CaptureSetupTask {
void run() throws IOException;
}
private void startCalibrationCaptureInternal(CastSettings settings) throws IOException {
CastResolution.Size size = CastResolution.calibrationTestSize(this);
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<IOException> 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;
}
private void startCameraCaptureInternal(CastSettings settings) throws IOException {
DisplayMetrics metrics = new DisplayMetrics();
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
wm.getDefaultDisplay().getRealMetrics(metrics);
@@ -402,37 +390,7 @@ public class ScreenCastService extends Service implements
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);
AtomicReference<IOException> error = new AtomicReference<>();
mainHandler.post(() -> {
try {
startCaptureOnMainThread(resultCode, data, settings);
} catch (Exception e) {
if (e instanceof IOException) {
error.set((IOException) e);
} else {
error.set(new IOException(e.getMessage() != null ? e.getMessage() : e.toString(), e));
}
} finally {
latch.countDown();
}
});
try {
if (!latch.await(15, TimeUnit.SECONDS)) {
throw new IOException("Capture setup timeout");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted", e);
}
if (error.get() != null) {
throw error.get();
}
return;
}
private void startCaptureInternal(int resultCode, Intent data, CastSettings settings) throws IOException {
MediaProjectionManager mgr =
(MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
projection = mgr.getMediaProjection(resultCode, data);
@@ -446,7 +404,7 @@ public class ScreenCastService extends Service implements
Log.w(TAG, "MediaProjection stopped");
stopping.set(true);
}
}, mainHandler);
}, projectionHandler != null ? projectionHandler : mainHandler);
DisplayMetrics metrics = new DisplayMetrics();
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
@@ -459,6 +417,9 @@ public class ScreenCastService extends Service implements
String mode = session.getTransport().getModeLabel();
updateStatus(getString(R.string.casting_active, mode, captureWidth, captureHeight));
if (videoEncoder != null) {
videoEncoder.requestKeyframe();
}
}
private void setupVideoPipeline(CastResolution.Size size, CastSettings settings, boolean calibration)
@@ -502,7 +463,8 @@ public class ScreenCastService extends Service implements
}
Surface surface = videoEncoder.prepare(captureWidth, captureHeight, mime, effectiveParams, videoCb);
encoderKeepalive = new EncoderKeepalive();
Handler keepaliveHandler = projectionHandler != null ? projectionHandler : mainHandler;
encoderKeepalive = new EncoderKeepalive(keepaliveHandler);
encoderKeepalive.start(videoEncoder);
if (projection != null) {
virtualDisplay = projection.createVirtualDisplay(
@@ -536,11 +498,12 @@ public class ScreenCastService extends Service implements
@Override
public void onDisplayChanged(int displayId) {
if (displayId == Display.DEFAULT_DISPLAY) {
mainHandler.post(ScreenCastService.this::maybeReconfigureForRotation);
runOnProjectionThread(ScreenCastService.this::maybeReconfigureForRotation);
}
}
};
displayManager.registerDisplayListener(displayListener, mainHandler);
Handler listenerHandler = projectionHandler != null ? projectionHandler : mainHandler;
displayManager.registerDisplayListener(displayListener, listenerHandler);
}
private void unregisterDisplayListener() {
@@ -583,6 +546,7 @@ public class ScreenCastService extends Service implements
if (fanoutPump != null) {
s.encodeQueueDepth = fanoutPump.getQueueDepth();
s.droppedPFrames = fanoutPump.getDroppedPFrames();
s.droppedAudioFrames = fanoutPump.getDroppedAudioFrames();
} else if (sendPump != null) {
s.encodeQueueDepth = sendPump.getQueueDepth();
s.droppedPFrames = sendPump.getDroppedPFrames();
@@ -635,7 +599,7 @@ public class ScreenCastService extends Service implements
long idleMs = lastSignificantFrameMs > 0
? System.currentTimeMillis() - lastSignificantFrameMs : 0;
if (idleMs > CastConfig.KEYFRAME_KEEPALIVE_MS && videoEncoder != null) {
videoEncoder.requestKeyframe();
runOnProjectionThread(() -> videoEncoder.requestKeyframe());
}
if (effectiveParams != null && next.videoBitrate != effectiveParams.videoBitrate) {
Log.d(TAG, "Tuning bitrate " + (next.videoBitrate / 1000) + " kbps (next pipeline)");
@@ -664,11 +628,22 @@ public class ScreenCastService extends Service implements
if (dw < Math.max(32, captureWidth / 10) && dh < Math.max(32, captureHeight / 10)) {
return;
}
try {
setupVideoPipeline(size, activeSettings, false);
Log.i(TAG, "Adaptive capture " + captureWidth + "x" + captureHeight);
} catch (IOException e) {
Log.w(TAG, "Adaptive resize failed", e);
runOnProjectionThread(() -> {
try {
setupVideoPipeline(size, activeSettings, false);
Log.i(TAG, "Adaptive capture " + captureWidth + "x" + captureHeight);
} catch (IOException e) {
Log.w(TAG, "Adaptive resize failed", e);
}
});
}
private void runOnProjectionThread(Runnable action) {
Handler handler = projectionHandler != null ? projectionHandler : mainHandler;
if (Looper.myLooper() == handler.getLooper()) {
action.run();
} else {
handler.post(action);
}
}
@@ -815,24 +790,7 @@ public class ScreenCastService extends Service implements
stopping.set(true);
casting.set(false);
CastActiveState.setSenderCasting(false);
unregisterDisplayListener();
networkFeedback = null;
if (virtualDisplay != null) {
virtualDisplay.release();
virtualDisplay = null;
}
if (encoderKeepalive != null) {
encoderKeepalive.stop();
encoderKeepalive = null;
}
if (cameraCapture != null) {
cameraCapture.stop();
cameraCapture = null;
}
if (calibrationDriver != null) {
calibrationDriver.release();
calibrationDriver = null;
}
calibrationMode = false;
cameraMode = false;
if (audioCapture != null) {
@@ -843,13 +801,30 @@ public class ScreenCastService extends Service implements
audioEncoder.stop();
audioEncoder = null;
}
if (videoEncoder != null) {
videoEncoder.stop();
videoEncoder = null;
Handler ph = projectionHandler;
if (ph != null) {
CountDownLatch latch = new CountDownLatch(1);
ph.post(() -> {
releaseProjectionCapture();
latch.countDown();
});
try {
latch.await(3, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
} else {
releaseProjectionCapture();
}
if (projection != null) {
projection.stop();
projection = null;
if (projectionThread != null) {
projectionThread.quitSafely();
try {
projectionThread.join(1500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
projectionThread = null;
projectionHandler = null;
}
if (multiCoordinator != null) {
multiCoordinator.sendGoodbyeAll();
@@ -918,6 +893,7 @@ public class ScreenCastService extends Service implements
snap.sendQueueDepth = sendPump.getQueueDepth();
} else if (fanoutPump != null) {
snap.sendDroppedVideoP = fanoutPump.getDroppedPFrames();
snap.sendDroppedAudio = fanoutPump.getDroppedAudioFrames();
snap.sendQueueDepth = fanoutPump.getQueueDepth();
}
return snap;
@@ -935,7 +911,8 @@ public class ScreenCastService extends Service implements
? activeSettings.getCaptureMode().name() : "");
}
recorder.mergeLoss(buildSenderLossSnapshot());
new Thread(() -> recorder.finish(getApplicationContext()), "SessionStatsSave").start();
com.foxx.androidcast.util.CastBackgroundExecutor.execute(
() -> recorder.finish(getApplicationContext()));
}
private void logEncodedCodecSample(String mime, byte[] frameData) {
@@ -970,8 +947,23 @@ public class ScreenCastService extends Service implements
if (stopping.get()) {
return;
}
sendSafe(CastProtocol.MSG_STREAM_CONFIG,
() -> CastProtocol.streamConfigPayload(w, h, csd0, csd1));
try {
byte[] payload = CastProtocol.streamConfigPayload(w, h, csd0, csd1);
if (fanoutPump != null && multiCoordinator != null) {
multiCoordinator.broadcast(CastProtocol.MSG_STREAM_CONFIG, payload);
} else {
sendSafe(CastProtocol.MSG_STREAM_CONFIG, () -> payload);
}
Log.i(TAG, "Stream config " + w + "x" + h
+ " peers=" + (multiCoordinator != null ? multiCoordinator.getPeers().size() : 1));
runOnProjectionThread(() -> {
if (videoEncoder != null) {
videoEncoder.requestKeyframe();
}
});
} catch (IOException e) {
Log.w(TAG, "Stream config failed: " + e.getMessage());
}
}
@Override
@@ -1013,15 +1005,11 @@ public class ScreenCastService extends Service implements
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);
@@ -1037,7 +1025,7 @@ public class ScreenCastService extends Service implements
if (mime == null || mime.isEmpty()) {
return;
}
mainHandler.post(() -> {
runOnProjectionThread(() -> {
try {
startCalibrationVideoEncoder(mime, effectiveParams, buildVideoEncoderCallback(mime), false);
} catch (IOException e) {
@@ -1047,6 +1035,34 @@ public class ScreenCastService extends Service implements
});
}
private void releaseProjectionCapture() {
unregisterDisplayListener();
if (virtualDisplay != null) {
virtualDisplay.release();
virtualDisplay = null;
}
if (encoderKeepalive != null) {
encoderKeepalive.stop();
encoderKeepalive = null;
}
if (cameraCapture != null) {
cameraCapture.stop();
cameraCapture = null;
}
if (calibrationDriver != null) {
calibrationDriver.release();
calibrationDriver = null;
}
if (videoEncoder != null) {
videoEncoder.stop();
videoEncoder = null;
}
if (projection != null) {
projection.stop();
projection = null;
}
}
@Override
public IBinder onBind(Intent intent) {
return binder;

View File

@@ -33,7 +33,7 @@ public class VideoEncoder {
private Callback callback;
private int width;
private int height;
private boolean running;
private volatile boolean running;
private Thread drainThread;
private String mime;

View File

@@ -0,0 +1,61 @@
package com.foxx.androidcast.sender.calibration;
import android.content.Context;
import android.graphics.BitmapFactory;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastResolution;
import com.foxx.androidcast.R;
/**
* Encoder size for calibration mode — derived from the chart asset (or procedural fallback),
* not a fixed resolution.
*/
public final class CalibrationSourceDimensions {
private static final int PROCEDURAL_FALLBACK_W = 500;
private static final int PROCEDURAL_FALLBACK_H = 400;
private CalibrationSourceDimensions() {}
public static CastResolution.Size resolve(Context context) {
int w = PROCEDURAL_FALLBACK_W;
int h = PROCEDURAL_FALLBACK_H;
if (context != null) {
BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inJustDecodeBounds = true;
BitmapFactory.decodeResource(
context.getApplicationContext().getResources(),
R.drawable.calibration_chart_reference,
opts);
if (opts.outWidth > 0 && opts.outHeight > 0) {
w = opts.outWidth;
h = opts.outHeight;
}
}
int[] capped = capLongEdge(even(w), even(h), CastConfig.CALIBRATION_MAX_LONG_EDGE);
return new CastResolution.Size(capped[0], capped[1], 240);
}
/** Visible for unit tests without Android resources. */
public static CastResolution.Size fromIntrinsic(int width, int height, int maxLongEdge) {
int[] capped = capLongEdge(even(width), even(height), maxLongEdge);
return new CastResolution.Size(capped[0], capped[1], 240);
}
private static int[] capLongEdge(int width, int height, int maxLong) {
if (maxLong <= 0) {
return new int[] {width, height};
}
int longEdge = Math.max(width, height);
if (longEdge <= maxLong) {
return new int[] {width, height};
}
float ratio = (float) maxLong / longEdge;
return new int[] {even(Math.max(2, (int) (width * ratio))),
even(Math.max(2, (int) (height * ratio)))};
}
private static int even(int v) {
return Math.max(2, v & ~1);
}
}

View File

@@ -46,11 +46,16 @@ public final class TvCalibrationGenerator {
}
private static void drawReferenceWithTransparentGray(Canvas canvas, Bitmap ref, int w, int h) {
Bitmap scaled = Bitmap.createScaledBitmap(ref, w, h, true);
int refW = ref.getWidth();
int refH = ref.getHeight();
float scale = Math.min((float) w / refW, (float) h / refH);
int dw = Math.max(2, (int) (refW * scale)) & ~1;
int dh = Math.max(2, (int) (refH * scale)) & ~1;
Bitmap scaled = Bitmap.createScaledBitmap(ref, dw, dh, 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);
int[] pixels = new int[dw * dh];
keyed.getPixels(pixels, 0, dw, 0, 0, dw, dh);
for (int i = 0; i < pixels.length; i++) {
int p = pixels[i];
int r = (p >> 16) & 0xff;
@@ -60,8 +65,10 @@ public final class TvCalibrationGenerator {
pixels[i] = 0;
}
}
keyed.setPixels(pixels, 0, w, 0, 0, w, h);
canvas.drawBitmap(keyed, 0, 0, null);
keyed.setPixels(pixels, 0, dw, 0, 0, dw, dh);
float left = (w - dw) / 2f;
float top = (h - dh) / 2f;
canvas.drawBitmap(keyed, left, top, null);
keyed.recycle();
}

View File

@@ -0,0 +1,27 @@
package com.foxx.androidcast.util;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
/** Shared daemon executor for short background tasks (consent, session stats I/O). */
public final class CastBackgroundExecutor {
private static final AtomicInteger SEQ = new AtomicInteger();
private static final ExecutorService EXEC = Executors.newSingleThreadExecutor(new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "CastBackground-" + SEQ.incrementAndGet());
t.setDaemon(true);
return t;
}
});
private CastBackgroundExecutor() {}
public static void execute(Runnable task) {
if (task != null) {
EXEC.execute(task);
}
}
}

View File

@@ -1,5 +1,7 @@
package com.foxx.androidcast;
import com.foxx.androidcast.sender.calibration.CalibrationSourceDimensions;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
@@ -8,10 +10,10 @@ import static org.junit.Assert.assertTrue;
public class CastResolutionTest {
@Test
public void calibrationTestSizeIsLandscape16x9() {
CastResolution.Size size = CastResolution.calibrationTestSize();
assertEquals(960, size.width);
assertEquals(540, size.height);
public void calibrationSourceMatchesReferenceAssetSize() {
CastResolution.Size size = CalibrationSourceDimensions.fromIntrinsic(500, 400, 0);
assertEquals(500, size.width);
assertEquals(400, size.height);
assertTrue(size.width > size.height);
}
}

View File

@@ -0,0 +1,21 @@
package com.foxx.androidcast.discovery;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class DiscoverySelfFilterTest {
@Test
public void isLocalHost_detectsLoopback() {
assertTrue(DiscoverySelfFilter.isLocalHost("127.0.0.1"));
assertFalse(DiscoverySelfFilter.isLocalHost("192.168.33.106"));
}
@Test
public void isSelf_withoutContext_usesHostOnly() {
assertTrue(DiscoverySelfFilter.isSelf(null, "127.0.0.1", ""));
assertFalse(DiscoverySelfFilter.isSelf(null, "10.0.0.5", "other-uuid"));
}
}

View File

@@ -0,0 +1,24 @@
package com.foxx.androidcast.sender.calibration;
import static org.junit.Assert.assertEquals;
import com.foxx.androidcast.CastResolution;
import org.junit.Test;
public class CalibrationSourceDimensionsTest {
@Test
public void fromIntrinsic_usesAssetAspectAndEvenDimensions() {
CastResolution.Size size = CalibrationSourceDimensions.fromIntrinsic(500, 401, 0);
assertEquals(500, size.width);
assertEquals(400, size.height);
}
@Test
public void fromIntrinsic_capsLongEdgeWhenConfigured() {
CastResolution.Size size = CalibrationSourceDimensions.fromIntrinsic(2000, 1600, 1000);
assertEquals(1000, size.width);
assertEquals(800, size.height);
}
}

View File

@@ -19,6 +19,13 @@ public class PlaybackTextureTransformTest {
assertEquals(1f, scales[1], 0.01f);
}
@Test
public void calibrationChartOnTabletLetterboxes() {
float[] scales = scalesFor(1920, 1200, 500, 400);
assertEquals(0.78f, scales[0], 0.02f);
assertEquals(1f, scales[1], 0.01f);
}
private static float[] scalesFor(int viewW, int viewH, int videoW, int videoH) {
float videoAspect = (float) videoW / videoH;
float viewAspect = (float) viewW / viewH;

View File

@@ -5,6 +5,9 @@ pluginManagement {
gradlePluginPortal()
}
}
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0'
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {