mirror of
git://f0xx.org/android_cast
synced 2026-07-29 05:17:39 +03:00
before UX
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user