From 6b4cd9fc02553e4fc68280afaf5847e8e6a5e27d Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Sun, 17 May 2026 15:45:19 +0200 Subject: [PATCH] before UX --- .../java/com/foxx/androidcast/CastConfig.java | 6 + .../com/foxx/androidcast/CastResolution.java | 8 +- .../discovery/DiscoveryManager.java | 26 +- .../discovery/DiscoverySelfFilter.java | 67 +++++ .../androidcast/receiver/AudioDecoder.java | 83 +++--- .../receiver/ReceiverCastService.java | 113 +++++--- .../androidcast/receiver/ReceiverSession.java | 11 +- .../foxx/androidcast/sender/AudioCapture.java | 52 +++- .../foxx/androidcast/sender/AudioEncoder.java | 79 ++--- .../androidcast/sender/CastFanoutPump.java | 250 +++++++++------- .../androidcast/sender/EncoderKeepalive.java | 10 +- .../sender/MultiCastCoordinator.java | 21 +- .../androidcast/sender/ScreenCastService.java | 272 +++++++++--------- .../foxx/androidcast/sender/VideoEncoder.java | 2 +- .../CalibrationSourceDimensions.java | 61 ++++ .../calibration/TvCalibrationGenerator.java | 17 +- .../util/CastBackgroundExecutor.java | 27 ++ .../foxx/androidcast/CastResolutionTest.java | 10 +- .../discovery/DiscoverySelfFilterTest.java | 21 ++ .../CalibrationSourceDimensionsTest.java | 24 ++ .../ui/PlaybackTextureTransformTest.java | 7 + settings.gradle | 3 + 22 files changed, 815 insertions(+), 355 deletions(-) create mode 100644 app/src/main/java/com/foxx/androidcast/discovery/DiscoverySelfFilter.java create mode 100644 app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationSourceDimensions.java create mode 100644 app/src/main/java/com/foxx/androidcast/util/CastBackgroundExecutor.java create mode 100644 app/src/test/java/com/foxx/androidcast/discovery/DiscoverySelfFilterTest.java create mode 100644 app/src/test/java/com/foxx/androidcast/sender/calibration/CalibrationSourceDimensionsTest.java diff --git a/app/src/main/java/com/foxx/androidcast/CastConfig.java b/app/src/main/java/com/foxx/androidcast/CastConfig.java index 0241253..0bf801e 100644 --- a/app/src/main/java/com/foxx/androidcast/CastConfig.java +++ b/app/src/main/java/com/foxx/androidcast/CastConfig.java @@ -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 Wi‑Fi MTU with header). */ public static final int UDP_CHUNK_SIZE = 1200; diff --git a/app/src/main/java/com/foxx/androidcast/CastResolution.java b/app/src/main/java/com/foxx/androidcast/CastResolution.java index af3fad4..e0fb914 100644 --- a/app/src/main/java/com/foxx/androidcast/CastResolution.java +++ b/app/src/main/java/com/foxx/androidcast/CastResolution.java @@ -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); } } diff --git a/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java b/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java index 99aa02f..5759125 100644 --- a/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java +++ b/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java @@ -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 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); } } diff --git a/app/src/main/java/com/foxx/androidcast/discovery/DiscoverySelfFilter.java b/app/src/main/java/com/foxx/androidcast/discovery/DiscoverySelfFilter.java new file mode 100644 index 0000000..3bd7271 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/discovery/DiscoverySelfFilter.java @@ -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 localIpv4Addresses() { + Set out = new HashSet<>(); + try { + Enumeration 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; + } +} 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 79fb2f5..d6eb1f9 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java @@ -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 inputQueue = new LinkedBlockingQueue<>(256); - private long playbackEpochUs = -1; + private final LinkedBlockingQueue 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(); 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 5fd1ac7..4c5c3fa 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java @@ -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 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(); } 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 40acb61..eb6430f 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java @@ -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()) 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 9cabc34..a4293bb 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java +++ b/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java @@ -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); 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 017a356..04ef09e 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java +++ b/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java @@ -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); diff --git a/app/src/main/java/com/foxx/androidcast/sender/CastFanoutPump.java b/app/src/main/java/com/foxx/androidcast/sender/CastFanoutPump.java index 288fca9..c7743c1 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/CastFanoutPump.java +++ b/app/src/main/java/com/foxx/androidcast/sender/CastFanoutPump.java @@ -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 queue = new LinkedBlockingQueue<>(MAX_QUEUE); - private final AtomicBoolean running = new AtomicBoolean(false); - private final List sessions = new ArrayList<>(); - private Thread thread; - private Runnable onSendFailed; - private PassThroughNetworkControlPlane statsPlane; - private volatile int droppedPFrames; + 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 final List sessions = new ArrayList<>(); + private Thread thread; + private Runnable onSendFailed; + private PassThroughNetworkControlPlane statsPlane; + private volatile int droppedPFrames; + private volatile int droppedAudioFrames; - public void setSessions(List list) { - sessions.clear(); - if (list != null) { - sessions.addAll(list); - } - } - - public int getDroppedPFrames() { - return droppedPFrames; - } - - public int getQueueDepth() { - return queue.size(); - } - - public void start(List 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 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 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(); - } - } } - } } diff --git a/app/src/main/java/com/foxx/androidcast/sender/EncoderKeepalive.java b/app/src/main/java/com/foxx/androidcast/sender/EncoderKeepalive.java index 74f4b3e..6d0c1b8 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/EncoderKeepalive.java +++ b/app/src/main/java/com/foxx/androidcast/sender/EncoderKeepalive.java @@ -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() { 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 1384a33..6217d97 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java +++ b/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java @@ -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 { 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 c7d849c..9dd9b56 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java +++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java @@ -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 callbacks = new RemoteCallbackList<>(); private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private HandlerThread projectionThread; + private Handler projectionHandler; + private final AtomicReference 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 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 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 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; 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 015f952..7a08cac 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java +++ b/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java @@ -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; diff --git a/app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationSourceDimensions.java b/app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationSourceDimensions.java new file mode 100644 index 0000000..053fc60 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationSourceDimensions.java @@ -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); + } +} 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 index 11e839e..b72f67f 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/calibration/TvCalibrationGenerator.java +++ b/app/src/main/java/com/foxx/androidcast/sender/calibration/TvCalibrationGenerator.java @@ -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(); } diff --git a/app/src/main/java/com/foxx/androidcast/util/CastBackgroundExecutor.java b/app/src/main/java/com/foxx/androidcast/util/CastBackgroundExecutor.java new file mode 100644 index 0000000..ac2625a --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/util/CastBackgroundExecutor.java @@ -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); + } + } +} diff --git a/app/src/test/java/com/foxx/androidcast/CastResolutionTest.java b/app/src/test/java/com/foxx/androidcast/CastResolutionTest.java index 7f54b08..f5543f5 100644 --- a/app/src/test/java/com/foxx/androidcast/CastResolutionTest.java +++ b/app/src/test/java/com/foxx/androidcast/CastResolutionTest.java @@ -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); } } diff --git a/app/src/test/java/com/foxx/androidcast/discovery/DiscoverySelfFilterTest.java b/app/src/test/java/com/foxx/androidcast/discovery/DiscoverySelfFilterTest.java new file mode 100644 index 0000000..1bd76c9 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/discovery/DiscoverySelfFilterTest.java @@ -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")); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/sender/calibration/CalibrationSourceDimensionsTest.java b/app/src/test/java/com/foxx/androidcast/sender/calibration/CalibrationSourceDimensionsTest.java new file mode 100644 index 0000000..5a98a72 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/sender/calibration/CalibrationSourceDimensionsTest.java @@ -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); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/ui/PlaybackTextureTransformTest.java b/app/src/test/java/com/foxx/androidcast/ui/PlaybackTextureTransformTest.java index d03b14b..93df8db 100644 --- a/app/src/test/java/com/foxx/androidcast/ui/PlaybackTextureTransformTest.java +++ b/app/src/test/java/com/foxx/androidcast/ui/PlaybackTextureTransformTest.java @@ -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; diff --git a/settings.gradle b/settings.gradle index 7d6a306..84d5c92 100644 --- a/settings.gradle +++ b/settings.gradle @@ -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 {