mirror of
git://f0xx.org/android_cast
synced 2026-07-29 05:37:52 +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_CHANNEL_COUNT = 2;
|
||||||
public static final int AUDIO_BITRATE = 96_000;
|
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). */
|
/** Max UDP payload chunk (fits typical Wi‑Fi MTU with header). */
|
||||||
public static final int UDP_CHUNK_SIZE = 1200;
|
public static final int UDP_CHUNK_SIZE = 1200;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package com.foxx.androidcast;
|
package com.foxx.androidcast;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
import android.util.DisplayMetrics;
|
import android.util.DisplayMetrics;
|
||||||
|
|
||||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||||
|
import com.foxx.androidcast.sender.calibration.CalibrationSourceDimensions;
|
||||||
|
|
||||||
/** Computes encoder VirtualDisplay size from display metrics and user settings. */
|
/** Computes encoder VirtualDisplay size from display metrics and user settings. */
|
||||||
public final class CastResolution {
|
public final class CastResolution {
|
||||||
@@ -71,8 +73,8 @@ public final class CastResolution {
|
|||||||
return Math.max(2, v & ~1);
|
return Math.max(2, v & ~1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fixed 16:9 landscape chart size for calibration test mode (receiver letterboxing). */
|
/** Calibration encoder size from chart asset intrinsic dimensions. */
|
||||||
public static Size calibrationTestSize() {
|
public static Size calibrationTestSize(Context context) {
|
||||||
return new Size(960, 540, 240);
|
return CalibrationSourceDimensions.resolve(context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ public class DiscoveryManager {
|
|||||||
if (!running.compareAndSet(false, true)) {
|
if (!running.compareAndSet(false, true)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
purgeSelfFromDeviceMap();
|
||||||
notifyScanStarted();
|
notifyScanStarted();
|
||||||
executor.execute(this::browseLoop);
|
executor.execute(this::browseLoop);
|
||||||
}
|
}
|
||||||
@@ -355,6 +356,14 @@ public class DiscoveryManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String host = packet.getAddress().getHostAddress();
|
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;
|
String key = host + ":" + port;
|
||||||
DiscoveredDevice device = new DiscoveredDevice(name, host, port, transport,
|
DiscoveredDevice device = new DiscoveredDevice(name, host, port, transport,
|
||||||
System.currentTimeMillis(), videoCodecs, audioCodecs, placeholderId, protoVersion);
|
System.currentTimeMillis(), videoCodecs, audioCodecs, placeholderId, protoVersion);
|
||||||
@@ -374,6 +383,20 @@ public class DiscoveryManager {
|
|||||||
| ((b[off + 2] & 0xff) << 8) | (b[off + 3] & 0xff);
|
| ((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() {
|
private void notifyListener() {
|
||||||
if (listener == null) {
|
if (listener == null) {
|
||||||
return;
|
return;
|
||||||
@@ -381,7 +404,8 @@ public class DiscoveryManager {
|
|||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
List<DiscoveredDevice> fresh = new ArrayList<>();
|
List<DiscoveredDevice> fresh = new ArrayList<>();
|
||||||
for (DiscoveredDevice d : devices.values()) {
|
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);
|
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.io.IOException;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.concurrent.LinkedBlockingQueue;
|
import java.util.concurrent.LinkedBlockingQueue;
|
||||||
import java.util.concurrent.TimeUnit;
|
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 {
|
public class AudioDecoder {
|
||||||
private static final String TAG = "AudioDecoder";
|
private static final String TAG = "AudioDecoder";
|
||||||
private static final String MIME = MediaFormat.MIMETYPE_AUDIO_AAC;
|
private static final String MIME = MediaFormat.MIMETYPE_AUDIO_AAC;
|
||||||
|
private static final int MAX_INPUT_QUEUE = 48;
|
||||||
|
|
||||||
public interface RenderCallback {
|
public interface RenderCallback {
|
||||||
void onPcmRendered(int bytes, PcmLevelAnalyzer.Levels levels);
|
void onPcmRendered(int bytes, PcmLevelAnalyzer.Levels levels);
|
||||||
@@ -38,6 +38,7 @@ public class AudioDecoder {
|
|||||||
public long pcmAudibleBlocks;
|
public long pcmAudibleBlocks;
|
||||||
public long decodeErrors;
|
public long decodeErrors;
|
||||||
public long queueDrops;
|
public long queueDrops;
|
||||||
|
public long writeShortfalls;
|
||||||
public float lastPeak;
|
public float lastPeak;
|
||||||
public float lastRms;
|
public float lastRms;
|
||||||
}
|
}
|
||||||
@@ -45,8 +46,7 @@ public class AudioDecoder {
|
|||||||
private final Context appContext;
|
private final Context appContext;
|
||||||
private final HandlerThread workerThread = new HandlerThread("AudioDecoderCtrl");
|
private final HandlerThread workerThread = new HandlerThread("AudioDecoderCtrl");
|
||||||
private Handler worker;
|
private Handler worker;
|
||||||
private final LinkedBlockingQueue<PendingFrame> inputQueue = new LinkedBlockingQueue<>(256);
|
private final LinkedBlockingQueue<PendingFrame> inputQueue = new LinkedBlockingQueue<>(MAX_INPUT_QUEUE);
|
||||||
private long playbackEpochUs = -1;
|
|
||||||
private final Stats stats = new Stats();
|
private final Stats stats = new Stats();
|
||||||
|
|
||||||
private MediaCodec codec;
|
private MediaCodec codec;
|
||||||
@@ -58,6 +58,8 @@ public class AudioDecoder {
|
|||||||
private int channels = 2;
|
private int channels = 2;
|
||||||
private boolean useAdtsMode;
|
private boolean useAdtsMode;
|
||||||
private RenderCallback renderCallback;
|
private RenderCallback renderCallback;
|
||||||
|
private PendingFrame retryFrame;
|
||||||
|
private long lastDropLogMs;
|
||||||
|
|
||||||
private static final class PendingFrame {
|
private static final class PendingFrame {
|
||||||
final long ptsUs;
|
final long ptsUs;
|
||||||
@@ -105,10 +107,12 @@ public class AudioDecoder {
|
|||||||
createAudioTrack(sampleRate, channels);
|
createAudioTrack(sampleRate, channels);
|
||||||
configured = true;
|
configured = true;
|
||||||
draining = true;
|
draining = true;
|
||||||
|
retryFrame = null;
|
||||||
startDrainThread();
|
startDrainThread();
|
||||||
Log.i(TAG, "Audio ready " + sampleRate + "Hz ch=" + channels
|
Log.i(TAG, "Audio ready " + sampleRate + "Hz ch=" + channels
|
||||||
+ " mode=" + (useAdtsMode ? "ADTS" : "raw+csd")
|
+ " mode=" + (useAdtsMode ? "ADTS" : "raw+csd")
|
||||||
+ " csdLen=" + (csd0 != null ? csd0.length : 0));
|
+ " csdLen=" + (csd0 != null ? csd0.length : 0)
|
||||||
|
+ " qMax=" + MAX_INPUT_QUEUE);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
error[0] = e;
|
error[0] = e;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -133,7 +137,7 @@ public class AudioDecoder {
|
|||||||
|
|
||||||
private void startDrainThread() {
|
private void startDrainThread() {
|
||||||
drainThread = new Thread(this::drainLoop, "AudioDecoderDrain");
|
drainThread = new Thread(this::drainLoop, "AudioDecoderDrain");
|
||||||
drainThread.setPriority(Thread.MAX_PRIORITY - 2);
|
drainThread.setPriority(Thread.MAX_PRIORITY - 1);
|
||||||
drainThread.start();
|
drainThread.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +146,7 @@ public class AudioDecoder {
|
|||||||
? AudioFormat.CHANNEL_OUT_STEREO
|
? AudioFormat.CHANNEL_OUT_STEREO
|
||||||
: AudioFormat.CHANNEL_OUT_MONO;
|
: AudioFormat.CHANNEL_OUT_MONO;
|
||||||
int minBuf = AudioTrack.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT);
|
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()
|
track = new AudioTrack.Builder()
|
||||||
.setAudioAttributes(new AudioAttributes.Builder()
|
.setAudioAttributes(new AudioAttributes.Builder()
|
||||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
@@ -159,7 +163,7 @@ public class AudioDecoder {
|
|||||||
track.setVolume(1.0f);
|
track.setVolume(1.0f);
|
||||||
requestAudioFocus();
|
requestAudioFocus();
|
||||||
track.play();
|
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) {
|
public void queueFrame(long ptsUs, byte[] data) {
|
||||||
@@ -168,18 +172,33 @@ public class AudioDecoder {
|
|||||||
}
|
}
|
||||||
stats.framesQueued++;
|
stats.framesQueued++;
|
||||||
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
|
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
|
||||||
|
PendingFrame dropped = inputQueue.poll();
|
||||||
|
if (dropped != null) {
|
||||||
stats.queueDrops++;
|
stats.queueDrops++;
|
||||||
inputQueue.poll();
|
logDropBurst();
|
||||||
|
}
|
||||||
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
|
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
|
||||||
stats.queueDrops++;
|
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() {
|
private void drainLoop() {
|
||||||
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
||||||
while (draining) {
|
while (draining) {
|
||||||
try {
|
try {
|
||||||
|
if (retryFrame != null) {
|
||||||
|
feedFrameSync(retryFrame, info);
|
||||||
|
}
|
||||||
PendingFrame frame = inputQueue.poll(5, TimeUnit.MILLISECONDS);
|
PendingFrame frame = inputQueue.poll(5, TimeUnit.MILLISECONDS);
|
||||||
if (frame != null && codec != null) {
|
if (frame != null && codec != null) {
|
||||||
feedFrameSync(frame, info);
|
feedFrameSync(frame, info);
|
||||||
@@ -198,16 +217,19 @@ public class AudioDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void feedFrameSync(PendingFrame frame, MediaCodec.BufferInfo info) {
|
private void feedFrameSync(PendingFrame frame, MediaCodec.BufferInfo info) {
|
||||||
|
retryFrame = null;
|
||||||
byte[] data = frame.data;
|
byte[] data = frame.data;
|
||||||
if (useAdtsMode) {
|
if (useAdtsMode) {
|
||||||
data = AacAdtsHelper.wrapIfNeeded(data, sampleRate, channels);
|
data = AacAdtsHelper.wrapIfNeeded(data, sampleRate, channels);
|
||||||
}
|
}
|
||||||
int inIndex = codec.dequeueInputBuffer(10_000);
|
int inIndex = codec.dequeueInputBuffer(5_000);
|
||||||
if (inIndex < 0) {
|
if (inIndex < 0) {
|
||||||
|
retryFrame = frame;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ByteBuffer buffer = codec.getInputBuffer(inIndex);
|
ByteBuffer buffer = codec.getInputBuffer(inIndex);
|
||||||
if (buffer == null) {
|
if (buffer == null) {
|
||||||
|
retryFrame = frame;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
buffer.clear();
|
buffer.clear();
|
||||||
@@ -248,41 +270,36 @@ public class AudioDecoder {
|
|||||||
} else {
|
} else {
|
||||||
stats.pcmAudibleBlocks++;
|
stats.pcmAudibleBlocks++;
|
||||||
}
|
}
|
||||||
pacePlayback(info.presentationTimeUs);
|
int written = writePcm(pcm);
|
||||||
int written = writePcmBlocking(pcm);
|
|
||||||
if (written > 0) {
|
if (written > 0) {
|
||||||
stats.pcmBytesWritten += written;
|
stats.pcmBytesWritten += written;
|
||||||
if (renderCallback != null) {
|
if (renderCallback != null) {
|
||||||
renderCallback.onPcmRendered(written, levels);
|
renderCallback.onPcmRendered(written, levels);
|
||||||
}
|
}
|
||||||
|
} else if (written < pcm.length) {
|
||||||
|
stats.writeShortfalls++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
codec.releaseOutputBuffer(outIndex, false);
|
codec.releaseOutputBuffer(outIndex, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void pacePlayback(long ptsUs) {
|
private int writePcm(byte[] pcm) {
|
||||||
long nowUs = System.nanoTime() / 1000L;
|
if (track == null || pcm.length == 0) {
|
||||||
if (playbackEpochUs < 0) {
|
return 0;
|
||||||
playbackEpochUs = nowUs - ptsUs;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
long dueUs = playbackEpochUs + ptsUs;
|
|
||||||
long aheadUs = dueUs - nowUs;
|
|
||||||
if (aheadUs > 120_000L) {
|
|
||||||
try {
|
|
||||||
Thread.sleep(aheadUs / 1000L, (int) ((aheadUs % 1000L) * 1000L));
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
}
|
}
|
||||||
|
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 total = 0;
|
||||||
int offset = 0;
|
int offset = 0;
|
||||||
int idle = 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);
|
int w = track.write(pcm, offset, pcm.length - offset);
|
||||||
if (w > 0) {
|
if (w > 0) {
|
||||||
total += w;
|
total += w;
|
||||||
@@ -292,7 +309,7 @@ public class AudioDecoder {
|
|||||||
}
|
}
|
||||||
idle++;
|
idle++;
|
||||||
try {
|
try {
|
||||||
Thread.sleep(2);
|
Thread.sleep(1);
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
break;
|
break;
|
||||||
@@ -309,11 +326,11 @@ public class AudioDecoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stops playback immediately and drops queued AAC (sender ended cast). */
|
|
||||||
public void flushAndStop() {
|
public void flushAndStop() {
|
||||||
draining = false;
|
draining = false;
|
||||||
configured = false;
|
configured = false;
|
||||||
inputQueue.clear();
|
inputQueue.clear();
|
||||||
|
retryFrame = null;
|
||||||
final Object lock = new Object();
|
final Object lock = new Object();
|
||||||
worker.post(() -> {
|
worker.post(() -> {
|
||||||
releaseOnWorker();
|
releaseOnWorker();
|
||||||
@@ -337,7 +354,7 @@ public class AudioDecoder {
|
|||||||
private void releaseOnWorker() {
|
private void releaseOnWorker() {
|
||||||
draining = false;
|
draining = false;
|
||||||
configured = false;
|
configured = false;
|
||||||
playbackEpochUs = -1;
|
retryFrame = null;
|
||||||
inputQueue.clear();
|
inputQueue.clear();
|
||||||
if (drainThread != null) {
|
if (drainThread != null) {
|
||||||
drainThread.interrupt();
|
drainThread.interrupt();
|
||||||
@@ -360,8 +377,6 @@ public class AudioDecoder {
|
|||||||
try {
|
try {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
track.pause();
|
track.pause();
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
||||||
track.flush();
|
track.flush();
|
||||||
}
|
}
|
||||||
track.stop();
|
track.stop();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.app.Service;
|
|||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
|
import android.os.HandlerThread;
|
||||||
import android.os.IBinder;
|
import android.os.IBinder;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import android.os.PowerManager;
|
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.SessionStatsRecorder;
|
||||||
import com.foxx.androidcast.stats.SessionStatsStore;
|
import com.foxx.androidcast.stats.SessionStatsStore;
|
||||||
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||||
|
import com.foxx.androidcast.util.CastBackgroundExecutor;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
|
||||||
@@ -48,6 +50,9 @@ public class ReceiverCastService extends Service {
|
|||||||
|
|
||||||
private final RemoteCallbackList<ICastStatusCallback> callbacks = new RemoteCallbackList<>();
|
private final RemoteCallbackList<ICastStatusCallback> callbacks = new RemoteCallbackList<>();
|
||||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||||
|
private HandlerThread videoDecodeThread;
|
||||||
|
private Handler videoDecodeHandler;
|
||||||
|
private final Object audioDecoderLock = new Object();
|
||||||
|
|
||||||
private DiscoveryManager discovery;
|
private DiscoveryManager discovery;
|
||||||
private ReceiverSession session;
|
private ReceiverSession session;
|
||||||
@@ -60,6 +65,9 @@ public class ReceiverCastService extends Service {
|
|||||||
|
|
||||||
private int pendingWidth;
|
private int pendingWidth;
|
||||||
private int pendingHeight;
|
private int pendingHeight;
|
||||||
|
/** Stream config size used for letterboxing (not decoder padding). */
|
||||||
|
private int displayWidth;
|
||||||
|
private int displayHeight;
|
||||||
private byte[] pendingCsd0;
|
private byte[] pendingCsd0;
|
||||||
private byte[] pendingCsd1;
|
private byte[] pendingCsd1;
|
||||||
private boolean hasPendingConfig;
|
private boolean hasPendingConfig;
|
||||||
@@ -144,6 +152,9 @@ public class ReceiverCastService extends Service {
|
|||||||
super.onCreate();
|
super.onCreate();
|
||||||
CastNotifications.createChannels(this);
|
CastNotifications.createChannels(this);
|
||||||
SessionStatsStore.pruneOnAppStart(this);
|
SessionStatsStore.pruneOnAppStart(this);
|
||||||
|
videoDecodeThread = new HandlerThread("VideoDecode");
|
||||||
|
videoDecodeThread.start();
|
||||||
|
videoDecodeHandler = new Handler(videoDecodeThread.getLooper());
|
||||||
mainHandler.post(idleWatchdog);
|
mainHandler.post(idleWatchdog);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,12 +208,16 @@ public class ReceiverCastService extends Service {
|
|||||||
videoDecoder.release();
|
videoDecoder.release();
|
||||||
}
|
}
|
||||||
if (audioDecoder != null) {
|
if (audioDecoder != null) {
|
||||||
|
synchronized (audioDecoderLock) {
|
||||||
audioDecoder.release();
|
audioDecoder.release();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
videoDecoder = new VideoDecoder();
|
videoDecoder = new VideoDecoder();
|
||||||
|
synchronized (audioDecoderLock) {
|
||||||
audioDecoder = new AudioDecoder(this);
|
audioDecoder = new AudioDecoder(this);
|
||||||
audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered(
|
audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered(
|
||||||
pcmBytes, levels.peak, levels.rms, levels.silent));
|
pcmBytes, levels.peak, levels.rms, levels.silent));
|
||||||
|
}
|
||||||
|
|
||||||
session = new ReceiverSession(this, pin, settings, new ReceiverSession.Listener() {
|
session = new ReceiverSession(this, pin, settings, new ReceiverSession.Listener() {
|
||||||
@Override
|
@Override
|
||||||
@@ -243,6 +258,7 @@ public class ReceiverCastService extends Service {
|
|||||||
reconfiguring = false;
|
reconfiguring = false;
|
||||||
}
|
}
|
||||||
final int frameBytes = data != null ? data.length : 0;
|
final int frameBytes = data != null ? data.length : 0;
|
||||||
|
final byte[] frameData = data;
|
||||||
mainHandler.post(() -> {
|
mainHandler.post(() -> {
|
||||||
lastVideoFrameMs = System.currentTimeMillis();
|
lastVideoFrameMs = System.currentTimeMillis();
|
||||||
if (streamIdle) {
|
if (streamIdle) {
|
||||||
@@ -252,11 +268,15 @@ public class ReceiverCastService extends Service {
|
|||||||
synchronized (transportLoss) {
|
synchronized (transportLoss) {
|
||||||
transportLoss.recvVideoPackets++;
|
transportLoss.recvVideoPackets++;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
if (videoDecodeHandler != null) {
|
||||||
|
videoDecodeHandler.post(() -> {
|
||||||
if (videoDecoder != null) {
|
if (videoDecoder != null) {
|
||||||
videoDecoder.queueFrame(ptsUs, keyFrame, data);
|
videoDecoder.queueFrame(ptsUs, keyFrame, frameData);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onAudioConfig(int sampleRate, int channels, byte[] csd0) {
|
public void onAudioConfig(int sampleRate, int channels, byte[] csd0) {
|
||||||
@@ -274,13 +294,17 @@ public class ReceiverCastService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
final boolean accepted = accept;
|
final boolean accepted = accept;
|
||||||
if (accepted && audioDecoder != null) {
|
if (accepted) {
|
||||||
|
synchronized (audioDecoderLock) {
|
||||||
|
if (audioDecoder != null) {
|
||||||
try {
|
try {
|
||||||
audioDecoder.configure(sampleRate, channels, csd0);
|
audioDecoder.configure(sampleRate, channels, csd0);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Audio configure on config msg failed", e);
|
Log.e(TAG, "Audio configure on config msg failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
mainHandler.post(() -> onAudioConfigReceived(sampleRate, channels, csd0, accepted));
|
mainHandler.post(() -> onAudioConfigReceived(sampleRate, channels, csd0, accepted));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,9 +318,13 @@ public class ReceiverCastService extends Service {
|
|||||||
synchronized (ReceiverCastService.this) {
|
synchronized (ReceiverCastService.this) {
|
||||||
accept = Boolean.TRUE.equals(audioAccepted);
|
accept = Boolean.TRUE.equals(audioAccepted);
|
||||||
}
|
}
|
||||||
if (accept && audioDecoder != null) {
|
if (accept) {
|
||||||
|
synchronized (audioDecoderLock) {
|
||||||
|
if (audioDecoder != null) {
|
||||||
audioDecoder.queueFrame(ptsUs, data);
|
audioDecoder.queueFrame(ptsUs, data);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
mainHandler.post(() -> {
|
mainHandler.post(() -> {
|
||||||
streamMetrics.onAudioPacket(frameBytes);
|
streamMetrics.onAudioPacket(frameBytes);
|
||||||
synchronized (transportLoss) {
|
synchronized (transportLoss) {
|
||||||
@@ -360,21 +388,23 @@ public class ReceiverCastService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void sendAudioConsentAsync(final boolean accept) {
|
private void sendAudioConsentAsync(final boolean accept) {
|
||||||
new Thread(() -> {
|
CastBackgroundExecutor.execute(() -> {
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
session.sendAudioConsent(accept);
|
session.sendAudioConsent(accept);
|
||||||
}
|
}
|
||||||
}, "AudioConsent").start();
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void stopAudioPlaybackImmediate() {
|
private void stopAudioPlaybackImmediate() {
|
||||||
castEnded = true;
|
castEnded = true;
|
||||||
|
synchronized (audioDecoderLock) {
|
||||||
if (audioDecoder != null) {
|
if (audioDecoder != null) {
|
||||||
audioDecoder.flushAndStop();
|
audioDecoder.flushAndStop();
|
||||||
audioDecoder = new AudioDecoder(this);
|
audioDecoder = new AudioDecoder(this);
|
||||||
audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered(
|
audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered(
|
||||||
pcmBytes, levels.peak, levels.rms, levels.silent));
|
pcmBytes, levels.peak, levels.rms, levels.silent));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Log.i(TAG, "Audio playback stopped");
|
Log.i(TAG, "Audio playback stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,6 +418,8 @@ public class ReceiverCastService extends Service {
|
|||||||
negotiatedVideoMime = null;
|
negotiatedVideoMime = null;
|
||||||
pendingWidth = 0;
|
pendingWidth = 0;
|
||||||
pendingHeight = 0;
|
pendingHeight = 0;
|
||||||
|
displayWidth = 0;
|
||||||
|
displayHeight = 0;
|
||||||
hasPendingConfig = false;
|
hasPendingConfig = false;
|
||||||
resetDecoderState();
|
resetDecoderState();
|
||||||
enterAwaitingMode(R.string.playback_listening, true);
|
enterAwaitingMode(R.string.playback_listening, true);
|
||||||
@@ -442,6 +474,8 @@ public class ReceiverCastService extends Service {
|
|||||||
awaitingKeyframe = true;
|
awaitingKeyframe = true;
|
||||||
pendingWidth = 0;
|
pendingWidth = 0;
|
||||||
pendingHeight = 0;
|
pendingHeight = 0;
|
||||||
|
displayWidth = 0;
|
||||||
|
displayHeight = 0;
|
||||||
pendingCsd0 = null;
|
pendingCsd0 = null;
|
||||||
pendingCsd1 = null;
|
pendingCsd1 = null;
|
||||||
resetDecoderState();
|
resetDecoderState();
|
||||||
@@ -460,12 +494,14 @@ public class ReceiverCastService extends Service {
|
|||||||
if (accepted) {
|
if (accepted) {
|
||||||
updateStatus(getString(R.string.audio_playing));
|
updateStatus(getString(R.string.audio_playing));
|
||||||
} else {
|
} else {
|
||||||
|
synchronized (audioDecoderLock) {
|
||||||
if (audioDecoder != null) {
|
if (audioDecoder != null) {
|
||||||
audioDecoder.release();
|
audioDecoder.release();
|
||||||
audioDecoder = new AudioDecoder(this);
|
audioDecoder = new AudioDecoder(this);
|
||||||
audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered(
|
audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered(
|
||||||
pcmBytes, levels.peak, levels.rms, levels.silent));
|
pcmBytes, levels.peak, levels.rms, levels.silent));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
updateStatus(getString(R.string.audio_skipped));
|
updateStatus(getString(R.string.audio_skipped));
|
||||||
}
|
}
|
||||||
Log.i(TAG, "Audio consent: " + accepted);
|
Log.i(TAG, "Audio consent: " + accepted);
|
||||||
@@ -498,11 +534,13 @@ public class ReceiverCastService extends Service {
|
|||||||
openPlaybackAwaiting(R.string.playback_awaiting_stream);
|
openPlaybackAwaiting(R.string.playback_awaiting_stream);
|
||||||
pendingWidth = size.width;
|
pendingWidth = size.width;
|
||||||
pendingHeight = size.height;
|
pendingHeight = size.height;
|
||||||
|
displayWidth = size.width;
|
||||||
|
displayHeight = size.height;
|
||||||
pendingCsd0 = csd0;
|
pendingCsd0 = csd0;
|
||||||
pendingCsd1 = csd1;
|
pendingCsd1 = csd1;
|
||||||
hasPendingConfig = true;
|
hasPendingConfig = true;
|
||||||
decoderActive = false;
|
decoderActive = false;
|
||||||
notifyPlaybackDimensions(pendingWidth, pendingHeight);
|
notifyPlaybackDimensions(displayWidth, displayHeight);
|
||||||
scheduleDecoderConfigureRetries();
|
scheduleDecoderConfigureRetries();
|
||||||
if (sessionStatsRecorder != null) {
|
if (sessionStatsRecorder != null) {
|
||||||
sessionStatsRecorder.setResolution(pendingWidth, pendingHeight);
|
sessionStatsRecorder.setResolution(pendingWidth, pendingHeight);
|
||||||
@@ -664,12 +702,10 @@ public class ReceiverCastService extends Service {
|
|||||||
if (width <= 0 || height <= 0) {
|
if (width <= 0 || height <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pendingWidth = width;
|
|
||||||
pendingHeight = height;
|
|
||||||
decoderWidth = width;
|
decoderWidth = width;
|
||||||
decoderHeight = height;
|
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.setResolution(pendingWidth, pendingHeight);
|
||||||
recorder.mergeLoss(buildReceiverLossSnapshot());
|
recorder.mergeLoss(buildReceiverLossSnapshot());
|
||||||
new Thread(() -> recorder.finish(getApplicationContext()), "SessionStatsSave").start();
|
CastBackgroundExecutor.execute(() -> recorder.finish(getApplicationContext()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void attachSurface(Surface surface) {
|
private void attachSurface(Surface surface) {
|
||||||
@@ -853,10 +889,12 @@ public class ReceiverCastService extends Service {
|
|||||||
videoDecoder.release();
|
videoDecoder.release();
|
||||||
videoDecoder = null;
|
videoDecoder = null;
|
||||||
}
|
}
|
||||||
|
synchronized (audioDecoderLock) {
|
||||||
if (audioDecoder != null) {
|
if (audioDecoder != null) {
|
||||||
audioDecoder.release();
|
audioDecoder.release();
|
||||||
audioDecoder = null;
|
audioDecoder = null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
attachedSurface = null;
|
attachedSurface = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -876,6 +914,11 @@ public class ReceiverCastService extends Service {
|
|||||||
public void onDestroy() {
|
public void onDestroy() {
|
||||||
mainHandler.removeCallbacks(idleWatchdog);
|
mainHandler.removeCallbacks(idleWatchdog);
|
||||||
releaseAll();
|
releaseAll();
|
||||||
|
if (videoDecodeThread != null) {
|
||||||
|
videoDecodeThread.quitSafely();
|
||||||
|
videoDecodeThread = null;
|
||||||
|
videoDecodeHandler = null;
|
||||||
|
}
|
||||||
callbacks.kill();
|
callbacks.kill();
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ThreadFactory;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
/** Server: PIN auth, then forward encoded A/V to decoders. */
|
/** Server: PIN auth, then forward encoded A/V to decoders. */
|
||||||
@@ -58,7 +59,14 @@ public class ReceiverSession {
|
|||||||
private final String pin;
|
private final String pin;
|
||||||
private final CastSettings localSettings;
|
private final CastSettings localSettings;
|
||||||
private final Listener listener;
|
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 final AtomicBoolean running = new AtomicBoolean(false);
|
||||||
|
|
||||||
private CastSession session;
|
private CastSession session;
|
||||||
@@ -143,6 +151,7 @@ public class ReceiverSession {
|
|||||||
} else if (session == null) {
|
} else if (session == null) {
|
||||||
session = new CastSession(listenTransport);
|
session = new CastSession(listenTransport);
|
||||||
}
|
}
|
||||||
|
inboundSessionGate.reset();
|
||||||
networkFeedback = new NetworkFeedbackManager(session, false);
|
networkFeedback = new NetworkFeedbackManager(session, false);
|
||||||
if (networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) {
|
if (networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) {
|
||||||
((PassThroughNetworkControlPlane) networkFeedback.getPlane())
|
((PassThroughNetworkControlPlane) networkFeedback.getPlane())
|
||||||
|
|||||||
@@ -98,14 +98,34 @@ public class AudioCapture {
|
|||||||
int frameBytes = pcmFrameBytes();
|
int frameBytes = pcmFrameBytes();
|
||||||
byte[] micBuf = new byte[frameBytes];
|
byte[] micBuf = new byte[frameBytes];
|
||||||
byte[] copy = 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) {
|
while (running) {
|
||||||
if (!readFully(micRecord, micBuf, frameBytes)) {
|
if (!readFully(micRecord, micBuf, frameBytes)) {
|
||||||
continue;
|
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);
|
System.arraycopy(micBuf, 0, copy, 0, frameBytes);
|
||||||
long ptsUs = frameIndex * 20_000L;
|
long ptsUs = (now - captureEpochNs) / 1000L;
|
||||||
frameIndex++;
|
|
||||||
if (callback != null) {
|
if (callback != null) {
|
||||||
callback.onPcm(copy, frameBytes, ptsUs,
|
callback.onPcm(copy, frameBytes, ptsUs,
|
||||||
com.foxx.androidcast.media.PcmLevelAnalyzer.analyze(copy, frameBytes));
|
com.foxx.androidcast.media.PcmLevelAnalyzer.analyze(copy, frameBytes));
|
||||||
@@ -199,11 +219,32 @@ public class AudioCapture {
|
|||||||
byte[] playBuf = new byte[frameBytes];
|
byte[] playBuf = new byte[frameBytes];
|
||||||
byte[] micBuf = new byte[frameBytes];
|
byte[] micBuf = new byte[frameBytes];
|
||||||
byte[] mixOut = 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) {
|
while (running) {
|
||||||
if (!readFully(playbackRecord, playBuf, frameBytes)) {
|
if (!readFully(playbackRecord, playBuf, frameBytes)) {
|
||||||
continue;
|
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;
|
int mRead = micRecord != null ? micRecord.read(micBuf, 0, frameBytes) : 0;
|
||||||
System.arraycopy(playBuf, 0, mixOut, 0, frameBytes);
|
System.arraycopy(playBuf, 0, mixOut, 0, frameBytes);
|
||||||
if (mRead > 0 && !hasAudibleSamples(playBuf, frameBytes)) {
|
if (mRead > 0 && !hasAudibleSamples(playBuf, frameBytes)) {
|
||||||
@@ -216,8 +257,7 @@ public class AudioCapture {
|
|||||||
captureMode = mode;
|
captureMode = mode;
|
||||||
notifyMode(callback, captureMode);
|
notifyMode(callback, captureMode);
|
||||||
}
|
}
|
||||||
long ptsUs = frameIndex * 20_000L;
|
long ptsUs = (now - captureEpochNs) / 1000L;
|
||||||
frameIndex++;
|
|
||||||
if (callback != null) {
|
if (callback != null) {
|
||||||
PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(mixOut, frameBytes);
|
PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(mixOut, frameBytes);
|
||||||
callback.onPcm(mixOut, frameBytes, ptsUs, levels);
|
callback.onPcm(mixOut, frameBytes, ptsUs, levels);
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ public class AudioEncoder {
|
|||||||
void onEncodedFrame(long ptsUs, byte[] data);
|
void onEncodedFrame(long ptsUs, byte[] data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final Object inputLock = new Object();
|
||||||
private MediaCodec codec;
|
private MediaCodec codec;
|
||||||
private Callback callback;
|
private Callback callback;
|
||||||
private boolean running;
|
private volatile boolean running;
|
||||||
private Thread drainThread;
|
private Thread drainThread;
|
||||||
private long presentationBaseNs = -1;
|
private long droppedPcmFrames;
|
||||||
|
|
||||||
public void prepare(Callback callback) throws IOException {
|
public void prepare(Callback callback) throws IOException {
|
||||||
this.callback = callback;
|
this.callback = callback;
|
||||||
@@ -38,55 +39,65 @@ public class AudioEncoder {
|
|||||||
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
|
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
|
||||||
codec.start();
|
codec.start();
|
||||||
running = true;
|
running = true;
|
||||||
|
primeSilenceFrames();
|
||||||
drainThread = new Thread(this::drainLoop, "AudioEncoderDrain");
|
drainThread = new Thread(this::drainLoop, "AudioEncoderDrain");
|
||||||
drainThread.start();
|
drainThread.start();
|
||||||
startSilencePrimer();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Feeds silent PCM so AAC config is emitted even when playback capture is idle. */
|
/** Minimal silence so AAC emits config before mic PCM (no extra thread). */
|
||||||
private void startSilencePrimer() {
|
private void primeSilenceFrames() {
|
||||||
Thread primer = new Thread(() -> {
|
int frameBytes = CastConfig.AUDIO_SAMPLE_RATE * CastConfig.AUDIO_CHANNEL_COUNT * 2 / 50;
|
||||||
int frameBytes = CastConfig.AUDIO_SAMPLE_RATE * 2 * 2 / 50;
|
|
||||||
byte[] silence = new byte[frameBytes];
|
byte[] silence = new byte[frameBytes];
|
||||||
for (int i = 0; i < 40 && running; i++) {
|
for (int i = 0; i < 3; i++) {
|
||||||
queuePcm(silence, silence.length, i * 20_000L);
|
queuePcm(silence, frameBytes, i * 20_000L);
|
||||||
try {
|
|
||||||
Thread.sleep(20);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, "AudioSilencePrimer");
|
|
||||||
primer.setDaemon(true);
|
public long getDroppedPcmFrames() {
|
||||||
primer.start();
|
return droppedPcmFrames;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void queuePcm(byte[] pcm, int length, long ptsUs) {
|
public void queuePcm(byte[] pcm, int length, long ptsUs) {
|
||||||
if (codec == null || !running) {
|
if (!running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
synchronized (inputLock) {
|
||||||
|
MediaCodec c = codec;
|
||||||
|
if (c == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
int index = codec.dequeueInputBuffer(5_000);
|
int index = -1;
|
||||||
|
for (int attempt = 0; attempt < 4 && index < 0; attempt++) {
|
||||||
|
index = c.dequeueInputBuffer(attempt == 0 ? 0 : 3_000);
|
||||||
|
}
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
|
droppedPcmFrames++;
|
||||||
|
if (droppedPcmFrames == 1 || droppedPcmFrames % 50 == 0) {
|
||||||
|
Log.w(TAG, "Dropped PCM frame (encoder input busy), total=" + droppedPcmFrames);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ByteBuffer buffer = codec.getInputBuffer(index);
|
ByteBuffer buffer = c.getInputBuffer(index);
|
||||||
if (buffer == null) {
|
if (buffer == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
buffer.clear();
|
buffer.clear();
|
||||||
buffer.put(pcm, 0, length);
|
buffer.put(pcm, 0, length);
|
||||||
codec.queueInputBuffer(index, 0, length, ptsUs, 0);
|
c.queueInputBuffer(index, 0, length, ptsUs, 0);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.w(TAG, "queuePcm failed", e);
|
Log.w(TAG, "queuePcm failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void stop() {
|
public void stop() {
|
||||||
running = false;
|
running = false;
|
||||||
MediaCodec c = codec;
|
MediaCodec c;
|
||||||
|
synchronized (inputLock) {
|
||||||
|
c = codec;
|
||||||
codec = null;
|
codec = null;
|
||||||
|
}
|
||||||
if (drainThread != null) {
|
if (drainThread != null) {
|
||||||
try {
|
try {
|
||||||
drainThread.join(1000);
|
drainThread.join(1000);
|
||||||
|
|||||||
@@ -13,18 +13,26 @@ import java.util.concurrent.LinkedBlockingQueue;
|
|||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
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 {
|
public final class CastFanoutPump {
|
||||||
private static final String TAG = "CastFanoutPump";
|
private static final String TAG = "CastFanoutPump";
|
||||||
private static final int MAX_QUEUE = 48;
|
private static final int MAX_VIDEO_QUEUE = 40;
|
||||||
|
private static final int MAX_AUDIO_QUEUE = 96;
|
||||||
|
|
||||||
private final LinkedBlockingQueue<CastSendPump.Outgoing> queue = new LinkedBlockingQueue<>(MAX_QUEUE);
|
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 AtomicBoolean running = new AtomicBoolean(false);
|
||||||
private final List<CastSession> sessions = new ArrayList<>();
|
private final List<CastSession> sessions = new ArrayList<>();
|
||||||
private Thread thread;
|
private Thread thread;
|
||||||
private Runnable onSendFailed;
|
private Runnable onSendFailed;
|
||||||
private PassThroughNetworkControlPlane statsPlane;
|
private PassThroughNetworkControlPlane statsPlane;
|
||||||
private volatile int droppedPFrames;
|
private volatile int droppedPFrames;
|
||||||
|
private volatile int droppedAudioFrames;
|
||||||
|
|
||||||
public void setSessions(List<CastSession> list) {
|
public void setSessions(List<CastSession> list) {
|
||||||
sessions.clear();
|
sessions.clear();
|
||||||
@@ -37,8 +45,12 @@ public final class CastFanoutPump {
|
|||||||
return droppedPFrames;
|
return droppedPFrames;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int getDroppedAudioFrames() {
|
||||||
|
return droppedAudioFrames;
|
||||||
|
}
|
||||||
|
|
||||||
public int getQueueDepth() {
|
public int getQueueDepth() {
|
||||||
return queue.size();
|
return videoQueue.size() + audioQueue.size();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void start(List<CastSession> sessionList, Runnable onSendFailed,
|
public void start(List<CastSession> sessionList, Runnable onSendFailed,
|
||||||
@@ -50,11 +62,15 @@ public final class CastFanoutPump {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
thread = new Thread(this::run, "CastFanoutPump");
|
thread = new Thread(this::run, "CastFanoutPump");
|
||||||
|
thread.setPriority(Thread.NORM_PRIORITY + 1);
|
||||||
thread.start();
|
thread.start();
|
||||||
|
Log.i(TAG, "Started fan-out to " + sessions.size() + " peer(s)");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void stop() {
|
public void stop() {
|
||||||
running.set(false);
|
running.set(false);
|
||||||
|
videoQueue.clear();
|
||||||
|
audioQueue.clear();
|
||||||
if (thread != null) {
|
if (thread != null) {
|
||||||
thread.interrupt();
|
thread.interrupt();
|
||||||
try {
|
try {
|
||||||
@@ -64,7 +80,6 @@ public final class CastFanoutPump {
|
|||||||
}
|
}
|
||||||
thread = null;
|
thread = null;
|
||||||
}
|
}
|
||||||
queue.clear();
|
|
||||||
sessions.clear();
|
sessions.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,15 +88,14 @@ public final class CastFanoutPump {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
CastSendPump.Outgoing item = CastSendPump.Outgoing.video(ptsUs, keyFrame, annexBReadyPayload);
|
CastSendPump.Outgoing item = CastSendPump.Outgoing.video(ptsUs, keyFrame, annexBReadyPayload);
|
||||||
if (!queue.offer(item)) {
|
if (!videoQueue.offer(item)) {
|
||||||
if (!keyFrame) {
|
if (!keyFrame) {
|
||||||
CastSendPump.Outgoing dropped = queue.poll();
|
dropOneVideoPFrame();
|
||||||
if (dropped != null && !dropped.keyFrame) {
|
}
|
||||||
|
if (!videoQueue.offer(item) && !keyFrame) {
|
||||||
droppedPFrames++;
|
droppedPFrames++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
queue.offer(item);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void enqueue(byte type, byte[] payload) {
|
public void enqueue(byte type, byte[] payload) {
|
||||||
@@ -89,32 +103,48 @@ public final class CastFanoutPump {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
CastSendPump.Outgoing item = CastSendPump.Outgoing.raw(type, payload);
|
CastSendPump.Outgoing item = CastSendPump.Outgoing.raw(type, payload);
|
||||||
while (running.get() && !queue.offer(item)) {
|
boolean audio = type == CastProtocol.MSG_AUDIO_FRAME || type == CastProtocol.MSG_AUDIO_CONFIG;
|
||||||
queue.poll();
|
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() {
|
private void run() {
|
||||||
while (running.get()) {
|
while (running.get()) {
|
||||||
try {
|
try {
|
||||||
CastSendPump.Outgoing item = queue.poll(200, TimeUnit.MILLISECONDS);
|
CastSendPump.Outgoing item = audioQueue.poll();
|
||||||
|
if (item == null) {
|
||||||
|
item = videoQueue.poll(50, TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
if (item == null || sessions.isEmpty()) {
|
if (item == null || sessions.isEmpty()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
byte[] payload = item.buildPayload();
|
byte[] payload = item.buildPayload();
|
||||||
for (CastSession session : sessions) {
|
fanOut(item.type, payload);
|
||||||
if (session == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
session.send(item.type, payload);
|
|
||||||
if (statsPlane != null) {
|
|
||||||
statsPlane.recordSendBytes(payload.length + 5);
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
Log.w(TAG, "Send to peer failed: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
if (!running.get()) {
|
if (!running.get()) {
|
||||||
break;
|
break;
|
||||||
@@ -127,4 +157,22 @@ public final class CastFanoutPump {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void fanOut(byte type, byte[] payload) {
|
||||||
|
int peer = 0;
|
||||||
|
for (CastSession session : sessions) {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,18 @@ import com.foxx.androidcast.CastConfig;
|
|||||||
public final class EncoderKeepalive {
|
public final class EncoderKeepalive {
|
||||||
private static final String TAG = "EncoderKeepalive";
|
private static final String TAG = "EncoderKeepalive";
|
||||||
|
|
||||||
private final Handler handler = new Handler(Looper.getMainLooper());
|
private final Handler handler;
|
||||||
private VideoEncoder encoder;
|
private VideoEncoder encoder;
|
||||||
private boolean running;
|
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() {
|
private final Runnable tick = new Runnable() {
|
||||||
@Override
|
@Override
|
||||||
public void run() {
|
public void run() {
|
||||||
|
|||||||
@@ -139,9 +139,17 @@ public final class MultiCastCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public CastProtocol.Message pollReceive(int timeoutMs) throws IOException {
|
public CastProtocol.Message pollReceive(int timeoutMs) throws IOException {
|
||||||
|
if (peers.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
long deadline = System.currentTimeMillis() + timeoutMs;
|
||||||
CastProtocol.Message any = null;
|
CastProtocol.Message any = null;
|
||||||
for (ActivePeer p : peers) {
|
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) {
|
if (msg != null) {
|
||||||
any = msg;
|
any = msg;
|
||||||
}
|
}
|
||||||
@@ -149,6 +157,17 @@ public final class MultiCastCoordinator {
|
|||||||
return any;
|
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() {
|
public void sendGoodbyeAll() {
|
||||||
for (ActivePeer p : peers) {
|
for (ActivePeer p : peers) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import android.media.projection.MediaProjection;
|
|||||||
import android.media.projection.MediaProjectionManager;
|
import android.media.projection.MediaProjectionManager;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
|
import android.os.HandlerThread;
|
||||||
import android.os.IBinder;
|
import android.os.IBinder;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import android.os.PowerManager;
|
import android.os.PowerManager;
|
||||||
@@ -68,6 +69,9 @@ public class ScreenCastService extends Service implements
|
|||||||
|
|
||||||
private final RemoteCallbackList<ICastStatusCallback> callbacks = new RemoteCallbackList<>();
|
private final RemoteCallbackList<ICastStatusCallback> callbacks = new RemoteCallbackList<>();
|
||||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
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 stopping = new AtomicBoolean(false);
|
||||||
private final AtomicBoolean casting = new AtomicBoolean(false);
|
private final AtomicBoolean casting = new AtomicBoolean(false);
|
||||||
private final AtomicBoolean resourcesReleased = new AtomicBoolean(false);
|
private final AtomicBoolean resourcesReleased = new AtomicBoolean(false);
|
||||||
@@ -143,6 +147,9 @@ public class ScreenCastService extends Service implements
|
|||||||
SessionStatsStore.pruneOnAppStart(this);
|
SessionStatsStore.pruneOnAppStart(this);
|
||||||
CastNotifications.createChannels(this);
|
CastNotifications.createChannels(this);
|
||||||
com.foxx.androidcast.network.CastTransportFactory.init(this);
|
com.foxx.androidcast.network.CastTransportFactory.init(this);
|
||||||
|
projectionThread = new HandlerThread("CastProjection");
|
||||||
|
projectionThread.start();
|
||||||
|
projectionHandler = new Handler(projectionThread.getLooper());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -209,11 +216,11 @@ public class ScreenCastService extends Service implements
|
|||||||
deviceName != null ? deviceName : host, host, port, settings.getTransport(), 0, 0, 0));
|
deviceName != null ? deviceName : host, host, port, settings.getTransport(), 0, 0, 0));
|
||||||
}
|
}
|
||||||
multiCoordinator = new MultiCastCoordinator(this);
|
multiCoordinator = new MultiCastCoordinator(this);
|
||||||
multiCoordinator.setKeyframeRequester(() -> {
|
multiCoordinator.setKeyframeRequester(() -> runOnProjectionThread(() -> {
|
||||||
if (videoEncoder != null) {
|
if (videoEncoder != null) {
|
||||||
videoEncoder.requestKeyframe();
|
videoEncoder.requestKeyframe();
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
updateStatus(getString(R.string.connecting_to, host));
|
updateStatus(getString(R.string.connecting_to, host));
|
||||||
multiCoordinator.connectAndHandshake(targets, settings,
|
multiCoordinator.connectAndHandshake(targets, settings,
|
||||||
deviceName != null ? deviceName : "Sender", pin);
|
deviceName != null ? deviceName : "Sender", pin);
|
||||||
@@ -225,6 +232,7 @@ public class ScreenCastService extends Service implements
|
|||||||
session = primary.session;
|
session = primary.session;
|
||||||
settings = primary.settings;
|
settings = primary.settings;
|
||||||
settings.setNegotiatedVideoMime(primary.negotiatedMime);
|
settings.setNegotiatedVideoMime(primary.negotiatedMime);
|
||||||
|
final CastSettings captureSettings = settings;
|
||||||
updateStatus(getString(R.string.authenticating));
|
updateStatus(getString(R.string.authenticating));
|
||||||
networkFeedback = new NetworkFeedbackManager(session, true);
|
networkFeedback = new NetworkFeedbackManager(session, true);
|
||||||
PassThroughNetworkControlPlane plane =
|
PassThroughNetworkControlPlane plane =
|
||||||
@@ -244,15 +252,21 @@ public class ScreenCastService extends Service implements
|
|||||||
sessionStatsRecorder.mergeSettings(settings);
|
sessionStatsRecorder.mergeSettings(settings);
|
||||||
sessionStatsRecorder.setClientCount(peers.size());
|
sessionStatsRecorder.setClientCount(peers.size());
|
||||||
sessionStatsRecorder.setCodecs(settings.getNegotiatedVideoMime(), "AAC");
|
sessionStatsRecorder.setCodecs(settings.getNegotiatedVideoMime(), "AAC");
|
||||||
|
final int projResultCode = resultCode;
|
||||||
|
final Intent projData = data != null ? new Intent(data) : null;
|
||||||
if (calibration) {
|
if (calibration) {
|
||||||
startCalibrationCaptureOnMainThread(settings);
|
postCaptureSetup(() -> startCalibrationCaptureInternal(captureSettings));
|
||||||
} else if (camera) {
|
} else if (camera) {
|
||||||
startCameraCaptureOnMainThread(settings);
|
postCaptureSetup(() -> startCameraCaptureInternal(captureSettings));
|
||||||
} else {
|
} else {
|
||||||
startCaptureOnMainThread(resultCode, new Intent(data), settings);
|
postCaptureSetup(() -> startCaptureInternal(projResultCode, projData, captureSettings));
|
||||||
}
|
}
|
||||||
scheduleTuningTick();
|
scheduleTuningTick();
|
||||||
while (!stopping.get()) {
|
while (!stopping.get()) {
|
||||||
|
IOException setupErr = captureSetupError.get();
|
||||||
|
if (setupErr != null) {
|
||||||
|
throw setupErr;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (networkFeedback != null) {
|
if (networkFeedback != null) {
|
||||||
networkFeedback.tick();
|
networkFeedback.tick();
|
||||||
@@ -325,64 +339,38 @@ public class ScreenCastService extends Service implements
|
|||||||
throw new IOException(getString(R.string.error_connect_failed, host, port), last);
|
throw new IOException(getString(R.string.error_connect_failed, host, port), last);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startCalibrationCaptureOnMainThread(CastSettings settings) throws IOException {
|
private void postCaptureSetup(CaptureSetupTask task) {
|
||||||
if (Looper.myLooper() != mainHandler.getLooper()) {
|
captureSetupError.set(null);
|
||||||
CountDownLatch latch = new CountDownLatch(1);
|
Handler handler = projectionHandler != null ? projectionHandler : mainHandler;
|
||||||
AtomicReference<IOException> error = new AtomicReference<>();
|
handler.post(() -> {
|
||||||
mainHandler.post(() -> {
|
|
||||||
try {
|
try {
|
||||||
startCalibrationCaptureOnMainThread(settings);
|
task.run();
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.e(TAG, "Capture setup failed", e);
|
||||||
|
captureSetupError.set(e);
|
||||||
|
stopping.set(true);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
error.set(e instanceof IOException ? (IOException) e : new IOException(e.getMessage(), e));
|
Log.e(TAG, "Capture setup failed", e);
|
||||||
} finally {
|
captureSetupError.set(new IOException(
|
||||||
latch.countDown();
|
e.getMessage() != null ? e.getMessage() : e.toString(), e));
|
||||||
|
stopping.set(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
try {
|
|
||||||
if (!latch.await(15, TimeUnit.SECONDS)) {
|
|
||||||
throw new IOException("Calibration setup timeout");
|
|
||||||
}
|
}
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
@FunctionalInterface
|
||||||
throw new IOException("Interrupted", e);
|
private interface CaptureSetupTask {
|
||||||
|
void run() throws IOException;
|
||||||
}
|
}
|
||||||
if (error.get() != null) {
|
|
||||||
throw error.get();
|
private void startCalibrationCaptureInternal(CastSettings settings) throws IOException {
|
||||||
}
|
CastResolution.Size size = CastResolution.calibrationTestSize(this);
|
||||||
return;
|
|
||||||
}
|
|
||||||
CastResolution.Size size = CastResolution.calibrationTestSize();
|
|
||||||
setupVideoPipeline(size, settings, true);
|
setupVideoPipeline(size, settings, true);
|
||||||
startAudioPipeline(settings, true);
|
startAudioPipeline(settings, true);
|
||||||
updateStatus(getString(R.string.casting_calibration, captureWidth, captureHeight));
|
updateStatus(getString(R.string.casting_calibration, captureWidth, captureHeight));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startCameraCaptureOnMainThread(CastSettings settings) throws IOException {
|
private void startCameraCaptureInternal(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;
|
|
||||||
}
|
|
||||||
DisplayMetrics metrics = new DisplayMetrics();
|
DisplayMetrics metrics = new DisplayMetrics();
|
||||||
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
|
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
|
||||||
wm.getDefaultDisplay().getRealMetrics(metrics);
|
wm.getDefaultDisplay().getRealMetrics(metrics);
|
||||||
@@ -402,37 +390,7 @@ public class ScreenCastService extends Service implements
|
|||||||
updateStatus(getString(R.string.casting_camera, captureWidth, captureHeight));
|
updateStatus(getString(R.string.casting_camera, captureWidth, captureHeight));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startCaptureOnMainThread(int resultCode, Intent data, CastSettings settings) throws IOException {
|
private void startCaptureInternal(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
MediaProjectionManager mgr =
|
MediaProjectionManager mgr =
|
||||||
(MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
|
(MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
|
||||||
projection = mgr.getMediaProjection(resultCode, data);
|
projection = mgr.getMediaProjection(resultCode, data);
|
||||||
@@ -446,7 +404,7 @@ public class ScreenCastService extends Service implements
|
|||||||
Log.w(TAG, "MediaProjection stopped");
|
Log.w(TAG, "MediaProjection stopped");
|
||||||
stopping.set(true);
|
stopping.set(true);
|
||||||
}
|
}
|
||||||
}, mainHandler);
|
}, projectionHandler != null ? projectionHandler : mainHandler);
|
||||||
|
|
||||||
DisplayMetrics metrics = new DisplayMetrics();
|
DisplayMetrics metrics = new DisplayMetrics();
|
||||||
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
|
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
|
||||||
@@ -459,6 +417,9 @@ public class ScreenCastService extends Service implements
|
|||||||
|
|
||||||
String mode = session.getTransport().getModeLabel();
|
String mode = session.getTransport().getModeLabel();
|
||||||
updateStatus(getString(R.string.casting_active, mode, captureWidth, captureHeight));
|
updateStatus(getString(R.string.casting_active, mode, captureWidth, captureHeight));
|
||||||
|
if (videoEncoder != null) {
|
||||||
|
videoEncoder.requestKeyframe();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setupVideoPipeline(CastResolution.Size size, CastSettings settings, boolean calibration)
|
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);
|
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);
|
encoderKeepalive.start(videoEncoder);
|
||||||
if (projection != null) {
|
if (projection != null) {
|
||||||
virtualDisplay = projection.createVirtualDisplay(
|
virtualDisplay = projection.createVirtualDisplay(
|
||||||
@@ -536,11 +498,12 @@ public class ScreenCastService extends Service implements
|
|||||||
@Override
|
@Override
|
||||||
public void onDisplayChanged(int displayId) {
|
public void onDisplayChanged(int displayId) {
|
||||||
if (displayId == Display.DEFAULT_DISPLAY) {
|
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() {
|
private void unregisterDisplayListener() {
|
||||||
@@ -583,6 +546,7 @@ public class ScreenCastService extends Service implements
|
|||||||
if (fanoutPump != null) {
|
if (fanoutPump != null) {
|
||||||
s.encodeQueueDepth = fanoutPump.getQueueDepth();
|
s.encodeQueueDepth = fanoutPump.getQueueDepth();
|
||||||
s.droppedPFrames = fanoutPump.getDroppedPFrames();
|
s.droppedPFrames = fanoutPump.getDroppedPFrames();
|
||||||
|
s.droppedAudioFrames = fanoutPump.getDroppedAudioFrames();
|
||||||
} else if (sendPump != null) {
|
} else if (sendPump != null) {
|
||||||
s.encodeQueueDepth = sendPump.getQueueDepth();
|
s.encodeQueueDepth = sendPump.getQueueDepth();
|
||||||
s.droppedPFrames = sendPump.getDroppedPFrames();
|
s.droppedPFrames = sendPump.getDroppedPFrames();
|
||||||
@@ -635,7 +599,7 @@ public class ScreenCastService extends Service implements
|
|||||||
long idleMs = lastSignificantFrameMs > 0
|
long idleMs = lastSignificantFrameMs > 0
|
||||||
? System.currentTimeMillis() - lastSignificantFrameMs : 0;
|
? System.currentTimeMillis() - lastSignificantFrameMs : 0;
|
||||||
if (idleMs > CastConfig.KEYFRAME_KEEPALIVE_MS && videoEncoder != null) {
|
if (idleMs > CastConfig.KEYFRAME_KEEPALIVE_MS && videoEncoder != null) {
|
||||||
videoEncoder.requestKeyframe();
|
runOnProjectionThread(() -> videoEncoder.requestKeyframe());
|
||||||
}
|
}
|
||||||
if (effectiveParams != null && next.videoBitrate != effectiveParams.videoBitrate) {
|
if (effectiveParams != null && next.videoBitrate != effectiveParams.videoBitrate) {
|
||||||
Log.d(TAG, "Tuning bitrate " + (next.videoBitrate / 1000) + " kbps (next pipeline)");
|
Log.d(TAG, "Tuning bitrate " + (next.videoBitrate / 1000) + " kbps (next pipeline)");
|
||||||
@@ -664,12 +628,23 @@ public class ScreenCastService extends Service implements
|
|||||||
if (dw < Math.max(32, captureWidth / 10) && dh < Math.max(32, captureHeight / 10)) {
|
if (dw < Math.max(32, captureWidth / 10) && dh < Math.max(32, captureHeight / 10)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
runOnProjectionThread(() -> {
|
||||||
try {
|
try {
|
||||||
setupVideoPipeline(size, activeSettings, false);
|
setupVideoPipeline(size, activeSettings, false);
|
||||||
Log.i(TAG, "Adaptive capture " + captureWidth + "x" + captureHeight);
|
Log.i(TAG, "Adaptive capture " + captureWidth + "x" + captureHeight);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
Log.w(TAG, "Adaptive resize failed", 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startAudioPipeline(CastSettings settings, boolean calibrationOnlyMic) {
|
private void startAudioPipeline(CastSettings settings, boolean calibrationOnlyMic) {
|
||||||
@@ -815,24 +790,7 @@ public class ScreenCastService extends Service implements
|
|||||||
stopping.set(true);
|
stopping.set(true);
|
||||||
casting.set(false);
|
casting.set(false);
|
||||||
CastActiveState.setSenderCasting(false);
|
CastActiveState.setSenderCasting(false);
|
||||||
unregisterDisplayListener();
|
|
||||||
networkFeedback = null;
|
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;
|
calibrationMode = false;
|
||||||
cameraMode = false;
|
cameraMode = false;
|
||||||
if (audioCapture != null) {
|
if (audioCapture != null) {
|
||||||
@@ -843,13 +801,30 @@ public class ScreenCastService extends Service implements
|
|||||||
audioEncoder.stop();
|
audioEncoder.stop();
|
||||||
audioEncoder = null;
|
audioEncoder = null;
|
||||||
}
|
}
|
||||||
if (videoEncoder != null) {
|
Handler ph = projectionHandler;
|
||||||
videoEncoder.stop();
|
if (ph != null) {
|
||||||
videoEncoder = null;
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
ph.post(() -> {
|
||||||
|
releaseProjectionCapture();
|
||||||
|
latch.countDown();
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
latch.await(3, TimeUnit.SECONDS);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
}
|
}
|
||||||
if (projection != null) {
|
} else {
|
||||||
projection.stop();
|
releaseProjectionCapture();
|
||||||
projection = null;
|
}
|
||||||
|
if (projectionThread != null) {
|
||||||
|
projectionThread.quitSafely();
|
||||||
|
try {
|
||||||
|
projectionThread.join(1500);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
projectionThread = null;
|
||||||
|
projectionHandler = null;
|
||||||
}
|
}
|
||||||
if (multiCoordinator != null) {
|
if (multiCoordinator != null) {
|
||||||
multiCoordinator.sendGoodbyeAll();
|
multiCoordinator.sendGoodbyeAll();
|
||||||
@@ -918,6 +893,7 @@ public class ScreenCastService extends Service implements
|
|||||||
snap.sendQueueDepth = sendPump.getQueueDepth();
|
snap.sendQueueDepth = sendPump.getQueueDepth();
|
||||||
} else if (fanoutPump != null) {
|
} else if (fanoutPump != null) {
|
||||||
snap.sendDroppedVideoP = fanoutPump.getDroppedPFrames();
|
snap.sendDroppedVideoP = fanoutPump.getDroppedPFrames();
|
||||||
|
snap.sendDroppedAudio = fanoutPump.getDroppedAudioFrames();
|
||||||
snap.sendQueueDepth = fanoutPump.getQueueDepth();
|
snap.sendQueueDepth = fanoutPump.getQueueDepth();
|
||||||
}
|
}
|
||||||
return snap;
|
return snap;
|
||||||
@@ -935,7 +911,8 @@ public class ScreenCastService extends Service implements
|
|||||||
? activeSettings.getCaptureMode().name() : "");
|
? activeSettings.getCaptureMode().name() : "");
|
||||||
}
|
}
|
||||||
recorder.mergeLoss(buildSenderLossSnapshot());
|
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) {
|
private void logEncodedCodecSample(String mime, byte[] frameData) {
|
||||||
@@ -970,8 +947,23 @@ public class ScreenCastService extends Service implements
|
|||||||
if (stopping.get()) {
|
if (stopping.get()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sendSafe(CastProtocol.MSG_STREAM_CONFIG,
|
try {
|
||||||
() -> CastProtocol.streamConfigPayload(w, h, csd0, csd1));
|
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
|
@Override
|
||||||
@@ -1013,15 +1005,11 @@ public class ScreenCastService extends Service implements
|
|||||||
calibrationDriver = new com.foxx.androidcast.sender.calibration.CalibrationCastDriver(this, this);
|
calibrationDriver = new com.foxx.androidcast.sender.calibration.CalibrationCastDriver(this, this);
|
||||||
if (tryGles) {
|
if (tryGles) {
|
||||||
Surface surface = videoEncoder.prepare(captureWidth, captureHeight, mime, params, videoCb);
|
Surface surface = videoEncoder.prepare(captureWidth, captureHeight, mime, params, videoCb);
|
||||||
encoderKeepalive = new EncoderKeepalive();
|
|
||||||
encoderKeepalive.start(videoEncoder);
|
|
||||||
calibrationDriver.attachSurface(videoEncoder, surface, captureWidth, captureHeight, density);
|
calibrationDriver.attachSurface(videoEncoder, surface, captureWidth, captureHeight, density);
|
||||||
videoEncoder.requestKeyframe();
|
videoEncoder.requestKeyframe();
|
||||||
Log.i(TAG, "Calibration encoder (GLES try) " + captureWidth + "x" + captureHeight + " mime=" + mime);
|
Log.i(TAG, "Calibration encoder (GLES try) " + captureWidth + "x" + captureHeight + " mime=" + mime);
|
||||||
} else {
|
} else {
|
||||||
videoEncoder.prepareBufferInput(captureWidth, captureHeight, mime, params, videoCb);
|
videoEncoder.prepareBufferInput(captureWidth, captureHeight, mime, params, videoCb);
|
||||||
encoderKeepalive = new EncoderKeepalive();
|
|
||||||
encoderKeepalive.start(videoEncoder);
|
|
||||||
calibrationDriver.attachBuffer(videoEncoder, captureWidth, captureHeight, density);
|
calibrationDriver.attachBuffer(videoEncoder, captureWidth, captureHeight, density);
|
||||||
videoEncoder.requestKeyframe();
|
videoEncoder.requestKeyframe();
|
||||||
Log.i(TAG, "Calibration encoder (buffer) " + captureWidth + "x" + captureHeight + " mime=" + mime);
|
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()) {
|
if (mime == null || mime.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mainHandler.post(() -> {
|
runOnProjectionThread(() -> {
|
||||||
try {
|
try {
|
||||||
startCalibrationVideoEncoder(mime, effectiveParams, buildVideoEncoderCallback(mime), false);
|
startCalibrationVideoEncoder(mime, effectiveParams, buildVideoEncoderCallback(mime), false);
|
||||||
} catch (IOException e) {
|
} 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
|
@Override
|
||||||
public IBinder onBind(Intent intent) {
|
public IBinder onBind(Intent intent) {
|
||||||
return binder;
|
return binder;
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ public class VideoEncoder {
|
|||||||
private Callback callback;
|
private Callback callback;
|
||||||
private int width;
|
private int width;
|
||||||
private int height;
|
private int height;
|
||||||
private boolean running;
|
private volatile boolean running;
|
||||||
private Thread drainThread;
|
private Thread drainThread;
|
||||||
private String mime;
|
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) {
|
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);
|
Bitmap keyed = scaled.copy(Bitmap.Config.ARGB_8888, true);
|
||||||
scaled.recycle();
|
scaled.recycle();
|
||||||
int[] pixels = new int[w * h];
|
int[] pixels = new int[dw * dh];
|
||||||
keyed.getPixels(pixels, 0, w, 0, 0, w, h);
|
keyed.getPixels(pixels, 0, dw, 0, 0, dw, dh);
|
||||||
for (int i = 0; i < pixels.length; i++) {
|
for (int i = 0; i < pixels.length; i++) {
|
||||||
int p = pixels[i];
|
int p = pixels[i];
|
||||||
int r = (p >> 16) & 0xff;
|
int r = (p >> 16) & 0xff;
|
||||||
@@ -60,8 +65,10 @@ public final class TvCalibrationGenerator {
|
|||||||
pixels[i] = 0;
|
pixels[i] = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
keyed.setPixels(pixels, 0, w, 0, 0, w, h);
|
keyed.setPixels(pixels, 0, dw, 0, 0, dw, dh);
|
||||||
canvas.drawBitmap(keyed, 0, 0, null);
|
float left = (w - dw) / 2f;
|
||||||
|
float top = (h - dh) / 2f;
|
||||||
|
canvas.drawBitmap(keyed, left, top, null);
|
||||||
keyed.recycle();
|
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;
|
package com.foxx.androidcast;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.sender.calibration.CalibrationSourceDimensions;
|
||||||
|
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
@@ -8,10 +10,10 @@ import static org.junit.Assert.assertTrue;
|
|||||||
public class CastResolutionTest {
|
public class CastResolutionTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void calibrationTestSizeIsLandscape16x9() {
|
public void calibrationSourceMatchesReferenceAssetSize() {
|
||||||
CastResolution.Size size = CastResolution.calibrationTestSize();
|
CastResolution.Size size = CalibrationSourceDimensions.fromIntrinsic(500, 400, 0);
|
||||||
assertEquals(960, size.width);
|
assertEquals(500, size.width);
|
||||||
assertEquals(540, size.height);
|
assertEquals(400, size.height);
|
||||||
assertTrue(size.width > 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);
|
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) {
|
private static float[] scalesFor(int viewW, int viewH, int videoW, int videoH) {
|
||||||
float videoAspect = (float) videoW / videoH;
|
float videoAspect = (float) videoW / videoH;
|
||||||
float viewAspect = (float) viewW / viewH;
|
float viewAspect = (float) viewW / viewH;
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ pluginManagement {
|
|||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
plugins {
|
||||||
|
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0'
|
||||||
|
}
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
repositories {
|
repositories {
|
||||||
|
|||||||
Reference in New Issue
Block a user