mirror of
git://f0xx.org/android_cast
synced 2026-07-29 05:58:14 +03:00
snap
This commit is contained in:
@@ -29,10 +29,17 @@ android {
|
||||
buildFeatures {
|
||||
aidl true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.includeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.2.0'
|
||||
implementation 'com.google.android.gms:play-services-cronet:18.1.0'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
|
||||
<application
|
||||
|
||||
@@ -3,8 +3,12 @@ package com.foxx.androidcast;
|
||||
/** Shared constants for the LAN cast POC. */
|
||||
public final class CastConfig {
|
||||
public static final int DISCOVERY_PORT = 41234;
|
||||
/** One UDP browse pass duration (progress bar on sender). */
|
||||
public static final long DISCOVERY_SCAN_PASS_MS = 4_000;
|
||||
/** One UDP browse pass duration (sender shows countdown on Refresh). */
|
||||
public static final long DISCOVERY_SCAN_PASS_MS = 30_000;
|
||||
/** Idle gap between automatic browse passes. */
|
||||
public static final long DISCOVERY_PAUSE_MS = 30_000;
|
||||
/** How often the sender broadcasts a discover probe during a scan pass. */
|
||||
public static final long DISCOVERY_PROBE_INTERVAL_MS = 1_000;
|
||||
public static final int CAST_PORT = 41235;
|
||||
public static final String DISCOVERY_MAGIC = "ACAST1";
|
||||
|
||||
@@ -16,6 +20,19 @@ public final class CastConfig {
|
||||
|
||||
public static final String TRANSPORT_TCP = "tcp";
|
||||
public static final String TRANSPORT_UDP = "udp";
|
||||
public static final String TRANSPORT_QUIC = "quic";
|
||||
public static final String TRANSPORT_WEBRTC = "webrtc";
|
||||
/** Default cast transport (UDP); TCP/QUIC remain selectable in settings. */
|
||||
public static final String DEFAULT_TRANSPORT = TRANSPORT_UDP;
|
||||
/** Dedicated port for experimental QUIC transport (UDP/TCP framed). */
|
||||
public static final int QUIC_PORT = 41236;
|
||||
|
||||
public static int portForTransport(String transport) {
|
||||
if (TRANSPORT_QUIC.equals(transport)) {
|
||||
return QUIC_PORT;
|
||||
}
|
||||
return CAST_PORT;
|
||||
}
|
||||
|
||||
/** Seconds between IDR frames (1 = faster recovery, slightly more bandwidth). */
|
||||
public static final int VIDEO_I_FRAME_INTERVAL = 1;
|
||||
@@ -28,7 +45,7 @@ public final class CastConfig {
|
||||
|
||||
public static final int AUDIO_SAMPLE_RATE = 44100;
|
||||
public static final int AUDIO_CHANNEL_COUNT = 2;
|
||||
public static final int AUDIO_BITRATE = 128_000;
|
||||
public static final int AUDIO_BITRATE = 96_000;
|
||||
|
||||
/** Max UDP payload chunk (fits typical Wi‑Fi MTU with header). */
|
||||
public static final int UDP_CHUNK_SIZE = 1200;
|
||||
@@ -36,6 +53,9 @@ public final class CastConfig {
|
||||
/** Show diagnostics overlay on receiver playback (debug). */
|
||||
public static final boolean SHOW_CAST_DIAGNOSTICS = true;
|
||||
|
||||
/** Max retained session stat JSON files (room for one new file on next session). */
|
||||
public static final int SESSION_STATS_MAX_FILES = 20;
|
||||
|
||||
/** No video packets for this long → treat stream as stopped (robot overlay). */
|
||||
/** No inbound video this long → robot overlay (keep above ~2× slowest expected frame interval). */
|
||||
public static final long STREAM_IDLE_TIMEOUT_MS = 8_000;
|
||||
|
||||
@@ -70,4 +70,9 @@ public final class CastResolution {
|
||||
private static int even(int v) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,14 @@ public class CastSettings implements Serializable {
|
||||
/** Whole display (default). */
|
||||
FULL_SCREEN,
|
||||
/** Android 14+: system picker can target a single app (better app audio capture). */
|
||||
USER_CHOICE
|
||||
USER_CHOICE,
|
||||
/** Generated TV calibration pattern (no MediaProjection). */
|
||||
CALIBRATION_TEST,
|
||||
/**
|
||||
* Future: Camera2 preview as video source (not MediaProjection app picker).
|
||||
* {@link PlatformCastSupport#isCameraCaptureModeSupported()}
|
||||
*/
|
||||
CAMERA
|
||||
}
|
||||
|
||||
public enum Resolution {
|
||||
@@ -69,7 +76,7 @@ public class CastSettings implements Serializable {
|
||||
}
|
||||
}
|
||||
|
||||
private String transport = CastConfig.TRANSPORT_TCP;
|
||||
private String transport = CastConfig.DEFAULT_TRANSPORT;
|
||||
private Quality quality = Quality.MEDIUM;
|
||||
private Resolution resolution = Resolution.HD_720P;
|
||||
private boolean audioEnabled;
|
||||
|
||||
@@ -8,12 +8,15 @@ import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.foxx.androidcast.receiver.ReceiverActivity;
|
||||
import com.foxx.androidcast.sender.SenderActivity;
|
||||
import com.foxx.androidcast.stats.SessionStatsStore;
|
||||
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
SessionStatsStore.pruneOnAppStart(this);
|
||||
setContentView(R.layout.activity_main);
|
||||
com.foxx.androidcast.network.CastTransportFactory.init(this);
|
||||
PermissionHelper.request(this);
|
||||
|
||||
Button sendBtn = findViewById(R.id.btn_send);
|
||||
|
||||
@@ -25,6 +25,13 @@ public final class PlatformCastSupport {
|
||||
: CastSettings.CaptureMode.FULL_SCREEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera2 as a video source is separate from MediaProjection (app picker cannot select camera only).
|
||||
*/
|
||||
public static boolean isCameraCaptureModeSupported() {
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q;
|
||||
}
|
||||
|
||||
/** Applies default audio flag: on for Android 10+, off otherwise. */
|
||||
public static void applyDefaultAudioSetting(CastSettings settings) {
|
||||
settings.setAudioEnabled(isAudioCaptureSupported());
|
||||
|
||||
@@ -50,10 +50,36 @@ public final class SettingsUi {
|
||||
Spinner resolution = activity.findViewById(R.id.spinner_resolution);
|
||||
|
||||
bindSpinner(activity, transport, new String[]{
|
||||
activity.getString(R.string.transport_udp),
|
||||
activity.getString(R.string.transport_tcp),
|
||||
activity.getString(R.string.transport_udp)
|
||||
}, position -> settings.setTransport(
|
||||
position == 1 ? CastConfig.TRANSPORT_UDP : CastConfig.TRANSPORT_TCP));
|
||||
activity.getString(R.string.transport_quic),
|
||||
activity.getString(R.string.transport_webrtc)
|
||||
}, position -> {
|
||||
switch (position) {
|
||||
case 1:
|
||||
settings.setTransport(CastConfig.TRANSPORT_TCP);
|
||||
break;
|
||||
case 2:
|
||||
settings.setTransport(CastConfig.TRANSPORT_QUIC);
|
||||
break;
|
||||
case 3:
|
||||
settings.setTransport(CastConfig.TRANSPORT_WEBRTC);
|
||||
break;
|
||||
case 0:
|
||||
default:
|
||||
settings.setTransport(CastConfig.TRANSPORT_UDP);
|
||||
break;
|
||||
}
|
||||
});
|
||||
int transportIndex = 0;
|
||||
if (CastConfig.TRANSPORT_TCP.equals(settings.getTransport())) {
|
||||
transportIndex = 1;
|
||||
} else if (CastConfig.TRANSPORT_QUIC.equals(settings.getTransport())) {
|
||||
transportIndex = 2;
|
||||
} else if (CastConfig.TRANSPORT_WEBRTC.equals(settings.getTransport())) {
|
||||
transportIndex = 3;
|
||||
}
|
||||
transport.setSelection(transportIndex);
|
||||
|
||||
List<CastSettings.VideoCodec> codecs = CodecCatalog.supportedChoices();
|
||||
List<String> codecLabels = new ArrayList<>();
|
||||
@@ -106,29 +132,42 @@ public final class SettingsUi {
|
||||
if (captureMode == null) {
|
||||
return;
|
||||
}
|
||||
java.util.List<String> modes = new java.util.ArrayList<>();
|
||||
java.util.List<CastSettings.CaptureMode> modeValues = new java.util.ArrayList<>();
|
||||
modes.add(activity.getString(R.string.capture_full_screen));
|
||||
modeValues.add(CastSettings.CaptureMode.FULL_SCREEN);
|
||||
if (PlatformCastSupport.isCaptureUserChoiceSupported()) {
|
||||
bindSpinner(activity, captureMode, new String[]{
|
||||
activity.getString(R.string.capture_full_screen),
|
||||
activity.getString(R.string.capture_user_choice)
|
||||
}, position -> settings.setCaptureMode(
|
||||
position == 1 ? CastSettings.CaptureMode.USER_CHOICE : CastSettings.CaptureMode.FULL_SCREEN));
|
||||
captureMode.setSelection(settings.getCaptureMode() == CastSettings.CaptureMode.USER_CHOICE ? 1 : 0);
|
||||
captureMode.setEnabled(true);
|
||||
if (label != null) {
|
||||
label.setText(R.string.label_capture_mode);
|
||||
}
|
||||
modes.add(activity.getString(R.string.capture_user_choice));
|
||||
modeValues.add(CastSettings.CaptureMode.USER_CHOICE);
|
||||
} else {
|
||||
String unavailable = activity.getString(R.string.capture_user_choice_unavailable,
|
||||
Build.VERSION.SDK_INT);
|
||||
bindSpinner(activity, captureMode, new String[]{
|
||||
activity.getString(R.string.capture_full_screen),
|
||||
unavailable
|
||||
}, position -> settings.setCaptureMode(CastSettings.CaptureMode.FULL_SCREEN));
|
||||
captureMode.setSelection(0);
|
||||
captureMode.setEnabled(false);
|
||||
if (label != null) {
|
||||
label.setText(activity.getString(R.string.label_capture_mode_sender_only));
|
||||
modes.add(activity.getString(R.string.capture_user_choice_unavailable, Build.VERSION.SDK_INT));
|
||||
modeValues.add(CastSettings.CaptureMode.FULL_SCREEN);
|
||||
}
|
||||
modes.add(activity.getString(R.string.capture_calibration_test));
|
||||
modeValues.add(CastSettings.CaptureMode.CALIBRATION_TEST);
|
||||
if (PlatformCastSupport.isCameraCaptureModeSupported()) {
|
||||
modes.add(activity.getString(R.string.capture_camera));
|
||||
modeValues.add(CastSettings.CaptureMode.CAMERA);
|
||||
} else {
|
||||
modes.add(activity.getString(R.string.capture_camera_unavailable));
|
||||
modeValues.add(CastSettings.CaptureMode.FULL_SCREEN);
|
||||
}
|
||||
bindSpinner(activity, captureMode, modes.toArray(new String[0]), position -> {
|
||||
if (position >= 0 && position < modeValues.size()) {
|
||||
settings.setCaptureMode(modeValues.get(position));
|
||||
}
|
||||
});
|
||||
int sel = 0;
|
||||
for (int i = 0; i < modeValues.size(); i++) {
|
||||
if (modeValues.get(i) == settings.getCaptureMode()) {
|
||||
sel = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
captureMode.setSelection(sel);
|
||||
captureMode.setEnabled(true);
|
||||
if (label != null) {
|
||||
label.setText(R.string.label_capture_mode);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,10 @@ public final class CastDiagnosticsFormatter {
|
||||
|
||||
html.append("Congestion: ").append(colored(String.valueOf(congestionLevel), congestionLevel > 0));
|
||||
html.append("<br/>");
|
||||
|
||||
appendUdpLossSection(html, peer, lastLocal);
|
||||
appendProtectionSection(html, peer, lastLocal);
|
||||
|
||||
html.append("Ctrl: ").append(escape(ctrl));
|
||||
html.append("<br/>");
|
||||
html.append("Mode: ").append(escape(bitrateMode));
|
||||
@@ -261,6 +265,110 @@ public final class CastDiagnosticsFormatter {
|
||||
return html.toString();
|
||||
}
|
||||
|
||||
private static void appendUdpLossSection(StringBuilder html, NetworkStatsSnapshot peer,
|
||||
NetworkStatsSnapshot local) {
|
||||
long rx = pick(peer, local, true, false);
|
||||
long tx = pick(peer, local, true, true);
|
||||
long lost = val(peer, local, p -> p.udpReassemblyLost, l -> l.udpReassemblyLost);
|
||||
long invalid = val(peer, local, p -> p.udpDatagramsInvalid, l -> l.udpDatagramsInvalid);
|
||||
long dup = val(peer, local, p -> p.udpFragmentsDuplicate, l -> l.udpFragmentsDuplicate);
|
||||
long wireDrop = val(peer, local, p -> p.udpWireDropped, l -> l.udpWireDropped);
|
||||
long skipKey = val(peer, local, p -> p.recvVideoSkippedKey, l -> l.recvVideoSkippedKey);
|
||||
long vDecErr = val(peer, local, p -> p.recvVideoDecodeErrors, l -> l.recvVideoDecodeErrors);
|
||||
long aDecErr = val(peer, local, p -> p.recvAudioDecodeErrors, l -> l.recvAudioDecodeErrors);
|
||||
long aDrop = val(peer, local, p -> p.recvAudioQueueDrops, l -> l.recvAudioQueueDrops);
|
||||
long dropAudio = val(peer, local, p -> (long) p.droppedAudioFrames, l -> (long) l.droppedAudioFrames);
|
||||
|
||||
if (rx == 0 && tx == 0 && lost == 0 && invalid == 0) {
|
||||
return;
|
||||
}
|
||||
html.append("<font color='").append(COLOR_DIM).append("'>UDP:</font> ");
|
||||
html.append("rx ").append(rx).append(" / tx ").append(tx).append(" dg");
|
||||
if (lost > 0) {
|
||||
html.append(" ").append(colored("reasm-lost " + lost, true));
|
||||
}
|
||||
if (invalid > 0) {
|
||||
html.append(" ").append(colored("bad " + invalid, true));
|
||||
}
|
||||
if (dup > 0) {
|
||||
html.append(" dup ").append(dup);
|
||||
}
|
||||
if (wireDrop > 0) {
|
||||
html.append(" ").append(colored("sess-drop " + wireDrop, true));
|
||||
}
|
||||
html.append("<br/>");
|
||||
if (skipKey > 0 || vDecErr > 0 || aDecErr > 0 || aDrop > 0 || dropAudio > 0) {
|
||||
html.append("App drops: ");
|
||||
if (skipKey > 0) {
|
||||
html.append(colored("vid-wait-key " + skipKey, skipKey > 50));
|
||||
}
|
||||
if (vDecErr > 0) {
|
||||
html.append(' ').append(colored("vid-dec " + vDecErr, true));
|
||||
}
|
||||
if (aDecErr > 0) {
|
||||
html.append(' ').append(colored("aud-dec " + aDecErr, true));
|
||||
}
|
||||
if (aDrop > 0) {
|
||||
html.append(' ').append(colored("aud-q " + aDrop, true));
|
||||
}
|
||||
if (dropAudio > 0) {
|
||||
html.append(' ').append(colored("snd-aud " + dropAudio, true));
|
||||
}
|
||||
html.append("<br/>");
|
||||
}
|
||||
}
|
||||
|
||||
private static void appendProtectionSection(StringBuilder html, NetworkStatsSnapshot peer,
|
||||
NetworkStatsSnapshot local) {
|
||||
String mode = str(peer, local, p -> p.protectionMode, l -> l.protectionMode, "NONE");
|
||||
html.append("<font color='").append(COLOR_DIM).append("'>Protect:</font> ");
|
||||
html.append(escape(mode));
|
||||
html.append(" · FEC <font color='").append(COLOR_DIM).append("'>stub (off)</font>");
|
||||
html.append(" · NACK <font color='").append(COLOR_DIM).append("'>stub (off)</font>");
|
||||
long nack = val(peer, local, p -> p.nackStubRequests, l -> l.nackStubRequests);
|
||||
if (nack > 0) {
|
||||
html.append(" nack=").append(nack);
|
||||
}
|
||||
html.append("<br/>");
|
||||
}
|
||||
|
||||
private static long pick(NetworkStatsSnapshot peer, NetworkStatsSnapshot local,
|
||||
boolean preferLocal, boolean tx) {
|
||||
if (preferLocal && local != null) {
|
||||
return tx ? local.udpDatagramsTx : local.udpDatagramsRx;
|
||||
}
|
||||
if (peer != null) {
|
||||
return tx ? peer.udpDatagramsTx : peer.udpDatagramsRx;
|
||||
}
|
||||
return local != null ? (tx ? local.udpDatagramsTx : local.udpDatagramsRx) : 0;
|
||||
}
|
||||
|
||||
private static long val(NetworkStatsSnapshot peer, NetworkStatsSnapshot local,
|
||||
java.util.function.Function<NetworkStatsSnapshot, Long> peerFn,
|
||||
java.util.function.Function<NetworkStatsSnapshot, Long> localFn) {
|
||||
long v = 0;
|
||||
if (local != null) {
|
||||
v = Math.max(v, localFn.apply(local));
|
||||
}
|
||||
if (peer != null) {
|
||||
v = Math.max(v, peerFn.apply(peer));
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
private static String str(NetworkStatsSnapshot peer, NetworkStatsSnapshot local,
|
||||
java.util.function.Function<NetworkStatsSnapshot, String> peerFn,
|
||||
java.util.function.Function<NetworkStatsSnapshot, String> localFn,
|
||||
String def) {
|
||||
if (local != null && localFn.apply(local) != null && !localFn.apply(local).isEmpty()) {
|
||||
return localFn.apply(local);
|
||||
}
|
||||
if (peer != null && peerFn.apply(peer) != null && !peerFn.apply(peer).isEmpty()) {
|
||||
return peerFn.apply(peer);
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
private static List<String> buildHints(float inFps, float renderFps, int bwInKbps,
|
||||
int rttMs, long videoPackets) {
|
||||
List<String> hints = new ArrayList<>();
|
||||
|
||||
@@ -117,6 +117,10 @@ public final class CastDiagnosticsLabels {
|
||||
switch (mode) {
|
||||
case USER_CHOICE:
|
||||
return "Choose app or screen (Android 14+)";
|
||||
case CALIBRATION_TEST:
|
||||
return "Calibration test pattern";
|
||||
case CAMERA:
|
||||
return "Camera (Camera2, planned)";
|
||||
case FULL_SCREEN:
|
||||
default:
|
||||
return "Entire screen";
|
||||
|
||||
@@ -92,6 +92,10 @@ public class DiscoveryManager {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
public boolean isBrowsing() {
|
||||
return running.get();
|
||||
}
|
||||
|
||||
public void startBrowsing() {
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
return;
|
||||
@@ -148,7 +152,13 @@ public class DiscoveryManager {
|
||||
sock.setSoTimeout(300);
|
||||
byte[] buf = new byte[1024];
|
||||
long deadline = System.currentTimeMillis() + CastConfig.DISCOVERY_SCAN_PASS_MS;
|
||||
long nextProbeMs = System.currentTimeMillis();
|
||||
while (running.get() && System.currentTimeMillis() < deadline) {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now >= nextProbeMs) {
|
||||
sendDiscoveryProbe(sock);
|
||||
nextProbeMs = now + CastConfig.DISCOVERY_PROBE_INTERVAL_MS;
|
||||
}
|
||||
DatagramPacket packet = new DatagramPacket(buf, buf.length);
|
||||
try {
|
||||
sock.receive(packet);
|
||||
@@ -200,26 +210,25 @@ public class DiscoveryManager {
|
||||
try (DatagramSocket sock = new DatagramSocket()) {
|
||||
socket = sock;
|
||||
sock.setBroadcast(true);
|
||||
sock.setSoTimeout(400);
|
||||
while (running.get()) {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put("magic", CastConfig.DISCOVERY_MAGIC);
|
||||
json.put("name", deviceName);
|
||||
json.put("port", port);
|
||||
json.put("transport", transport);
|
||||
json.put("listening", listening);
|
||||
json.put("device_uuid", DeviceInfo.getDeviceUuid(context));
|
||||
byte[] jsonBytes = json.toString().getBytes(StandardCharsets.UTF_8);
|
||||
CastProtoHeader hdr = CastProtoHeader.forDiscovery(placeholderId, videoMask, audioMask);
|
||||
byte[] wire;
|
||||
try {
|
||||
wire = CastPacketFramer.frame(hdr, CastProtocol.MSG_HELLO, jsonBytes);
|
||||
} catch (Exception e) {
|
||||
wire = jsonBytes;
|
||||
sendAnnounce(sock, context, deviceName, port, transport, listening,
|
||||
placeholderId, videoMask, audioMask);
|
||||
long listenUntil = System.currentTimeMillis() + 2_000;
|
||||
while (running.get() && System.currentTimeMillis() < listenUntil) {
|
||||
try {
|
||||
byte[] buf = new byte[1024];
|
||||
DatagramPacket packet = new DatagramPacket(buf, buf.length);
|
||||
sock.receive(packet);
|
||||
if (isDiscoverProbe(packet)) {
|
||||
sendAnnounce(sock, context, deviceName, port, transport, listening,
|
||||
placeholderId, videoMask, audioMask);
|
||||
sendAnnounceUnicast(sock, context, deviceName, port, transport, listening,
|
||||
placeholderId, videoMask, audioMask, packet.getAddress());
|
||||
}
|
||||
} catch (java.net.SocketTimeoutException ignored) {
|
||||
}
|
||||
}
|
||||
InetAddress broadcast = InetAddress.getByName("255.255.255.255");
|
||||
DatagramPacket packet = new DatagramPacket(wire, wire.length, broadcast, CastConfig.DISCOVERY_PORT);
|
||||
sock.send(packet);
|
||||
Thread.sleep(2000);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (running.get()) {
|
||||
@@ -232,6 +241,73 @@ public class DiscoveryManager {
|
||||
}
|
||||
}
|
||||
|
||||
private static void sendDiscoveryProbe(DatagramSocket sock) {
|
||||
try {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put("magic", CastConfig.DISCOVERY_MAGIC);
|
||||
json.put("discover", true);
|
||||
byte[] bytes = json.toString().getBytes(StandardCharsets.UTF_8);
|
||||
InetAddress broadcast = InetAddress.getByName("255.255.255.255");
|
||||
DatagramPacket packet = new DatagramPacket(bytes, bytes.length, broadcast, CastConfig.DISCOVERY_PORT);
|
||||
sock.send(packet);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Discovery probe send failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isDiscoverProbe(DatagramPacket packet) {
|
||||
try {
|
||||
String text = new String(packet.getData(), packet.getOffset(), packet.getLength(),
|
||||
StandardCharsets.UTF_8);
|
||||
JSONObject json = new JSONObject(text);
|
||||
return CastConfig.DISCOVERY_MAGIC.equals(json.optString("magic"))
|
||||
&& json.optBoolean("discover", false);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void sendAnnounce(DatagramSocket sock, Context context, String deviceName, int port,
|
||||
String transport, boolean listening, int placeholderId, int videoMask, int audioMask)
|
||||
throws Exception {
|
||||
byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening,
|
||||
placeholderId, videoMask, audioMask);
|
||||
InetAddress broadcast = InetAddress.getByName("255.255.255.255");
|
||||
DatagramPacket packet = new DatagramPacket(wire, wire.length, broadcast, CastConfig.DISCOVERY_PORT);
|
||||
sock.send(packet);
|
||||
}
|
||||
|
||||
private static void sendAnnounceUnicast(DatagramSocket sock, Context context, String deviceName, int port,
|
||||
String transport, boolean listening, int placeholderId, int videoMask, int audioMask,
|
||||
InetAddress target) {
|
||||
try {
|
||||
byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening,
|
||||
placeholderId, videoMask, audioMask);
|
||||
DatagramPacket packet = new DatagramPacket(wire, wire.length, target, CastConfig.DISCOVERY_PORT);
|
||||
sock.send(packet);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Unicast announce failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] buildAnnounceWire(Context context, String deviceName, int port, String transport,
|
||||
boolean listening, int placeholderId, int videoMask, int audioMask) throws Exception {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put("magic", CastConfig.DISCOVERY_MAGIC);
|
||||
json.put("name", deviceName);
|
||||
json.put("port", port);
|
||||
json.put("transport", transport);
|
||||
json.put("listening", listening);
|
||||
json.put("device_uuid", DeviceInfo.getDeviceUuid(context));
|
||||
byte[] jsonBytes = json.toString().getBytes(StandardCharsets.UTF_8);
|
||||
CastProtoHeader hdr = CastProtoHeader.forDiscovery(placeholderId, videoMask, audioMask);
|
||||
try {
|
||||
return CastPacketFramer.frame(hdr, CastProtocol.MSG_HELLO, jsonBytes);
|
||||
} catch (Exception e) {
|
||||
return jsonBytes;
|
||||
}
|
||||
}
|
||||
|
||||
private void handlePacket(DatagramPacket packet) {
|
||||
try {
|
||||
byte[] raw = packet.getData();
|
||||
@@ -274,7 +350,7 @@ public class DiscoveryManager {
|
||||
}
|
||||
String name = json.optString("name", "Unknown");
|
||||
int port = json.optInt("port", CastConfig.CAST_PORT);
|
||||
String transport = json.optString("transport", CastConfig.TRANSPORT_TCP);
|
||||
String transport = json.optString("transport", CastConfig.DEFAULT_TRANSPORT);
|
||||
if (!json.optBoolean("listening", true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -72,4 +72,8 @@ public final class CodecNegotiator {
|
||||
public static boolean isH264(String mime) {
|
||||
return MediaFormat.MIMETYPE_VIDEO_AVC.equals(mime);
|
||||
}
|
||||
|
||||
public static boolean isHevc(String mime) {
|
||||
return MediaFormat.MIMETYPE_VIDEO_HEVC.equals(mime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.foxx.androidcast.media;
|
||||
|
||||
import android.media.MediaFormat;
|
||||
|
||||
/** Inspects encoded bitstream headers for diagnostics. */
|
||||
public final class EncodedStreamProbe {
|
||||
private EncodedStreamProbe() {}
|
||||
|
||||
public static String describeKeyframe(String mime, byte[] annexB) {
|
||||
if (annexB == null || annexB.length < 5) {
|
||||
return "empty";
|
||||
}
|
||||
int offset = 0;
|
||||
if (annexB[0] == 0 && annexB[1] == 0 && annexB[2] == 0 && annexB[3] == 1) {
|
||||
offset = 4;
|
||||
} else if (annexB[0] == 0 && annexB[1] == 0 && annexB[2] == 1) {
|
||||
offset = 3;
|
||||
}
|
||||
if (offset >= annexB.length) {
|
||||
return "no start code";
|
||||
}
|
||||
if (MediaFormat.MIMETYPE_VIDEO_HEVC.equals(mime)) {
|
||||
int nalType = (annexB[offset] >> 1) & 0x3F;
|
||||
return "HEVC NAL type " + nalType + " (" + annexB.length + " B)";
|
||||
}
|
||||
if (MediaFormat.MIMETYPE_VIDEO_AVC.equals(mime)) {
|
||||
int nalType = annexB[offset] & 0x1F;
|
||||
return "AVC NAL type " + nalType + " (" + annexB.length + " B)";
|
||||
}
|
||||
return "encoded " + annexB.length + " B";
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@ package com.foxx.androidcast.media;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
|
||||
/** Converts H.264 AVCC (length-prefixed) NAL units to Annex-B start codes for decoders. */
|
||||
/**
|
||||
* Converts H.264 / HEVC AVCC (length-prefixed) NAL units to Annex-B start codes for decoders.
|
||||
* MediaCodec encoders on Android typically emit AVCC; many decoders expect Annex-B in input buffers.
|
||||
*/
|
||||
public final class H264Bitstream {
|
||||
private static final byte[] START_CODE = {0, 0, 0, 1};
|
||||
|
||||
|
||||
52
app/src/main/java/com/foxx/androidcast/media/RgbToNv21.java
Normal file
52
app/src/main/java/com/foxx/androidcast/media/RgbToNv21.java
Normal file
@@ -0,0 +1,52 @@
|
||||
package com.foxx.androidcast.media;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
|
||||
/** ARGB bitmap to NV21 for YUV420 encoder input. */
|
||||
public final class RgbToNv21 {
|
||||
private RgbToNv21() {}
|
||||
|
||||
public static byte[] fromBitmap(Bitmap bitmap) {
|
||||
int width = bitmap.getWidth();
|
||||
int height = bitmap.getHeight();
|
||||
int[] argb = new int[width * height];
|
||||
bitmap.getPixels(argb, 0, width, 0, 0, width, height);
|
||||
return fromArgb(argb, width, height);
|
||||
}
|
||||
|
||||
public static byte[] fromArgb(int[] argb, int width, int height) {
|
||||
int frameSize = width * height;
|
||||
int ySize = frameSize;
|
||||
int uvSize = frameSize / 2;
|
||||
byte[] nv21 = new byte[ySize + uvSize];
|
||||
int yIndex = 0;
|
||||
int uvIndex = ySize;
|
||||
for (int j = 0; j < height; j++) {
|
||||
for (int i = 0; i < width; i++) {
|
||||
int color = argb[j * width + i];
|
||||
int r = (color >> 16) & 0xff;
|
||||
int g = (color >> 8) & 0xff;
|
||||
int b = color & 0xff;
|
||||
int y = ((66 * r + 129 * g + 25 * b + 128) >> 8) + 16;
|
||||
int u = ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128;
|
||||
int v = ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128;
|
||||
nv21[yIndex++] = (byte) clamp(y);
|
||||
if ((j & 1) == 0 && (i & 1) == 0) {
|
||||
nv21[uvIndex++] = (byte) clamp(v);
|
||||
nv21[uvIndex++] = (byte) clamp(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
return nv21;
|
||||
}
|
||||
|
||||
private static int clamp(int v) {
|
||||
if (v < 0) {
|
||||
return 0;
|
||||
}
|
||||
if (v > 255) {
|
||||
return 255;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
115
app/src/main/java/com/foxx/androidcast/media/RgbToYuv420.java
Normal file
115
app/src/main/java/com/foxx/androidcast/media/RgbToYuv420.java
Normal file
@@ -0,0 +1,115 @@
|
||||
package com.foxx.androidcast.media;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
|
||||
/**
|
||||
* ARGB to YUV420 planar (I420) or semi-planar (NV12) for MediaCodec buffer input.
|
||||
*/
|
||||
public final class RgbToYuv420 {
|
||||
public enum Layout {
|
||||
I420,
|
||||
NV12
|
||||
}
|
||||
|
||||
private RgbToYuv420() {}
|
||||
|
||||
public static byte[] fromBitmap(Bitmap bitmap, Layout layout) {
|
||||
int width = bitmap.getWidth();
|
||||
int height = bitmap.getHeight();
|
||||
int[] argb = new int[width * height];
|
||||
bitmap.getPixels(argb, 0, width, 0, 0, width, height);
|
||||
return fromArgb(argb, width, height, layout);
|
||||
}
|
||||
|
||||
public static byte[] fromArgb(int[] argb, int width, int height, Layout layout) {
|
||||
if (layout == Layout.NV12) {
|
||||
return toNv12(argb, width, height);
|
||||
}
|
||||
return toI420(argb, width, height);
|
||||
}
|
||||
|
||||
public static int bufferSize(int width, int height) {
|
||||
return width * height + (width * height) / 2;
|
||||
}
|
||||
|
||||
private static byte[] toI420(int[] argb, int width, int height) {
|
||||
int frameSize = width * height;
|
||||
int chromaW = (width + 1) / 2;
|
||||
int chromaH = (height + 1) / 2;
|
||||
int chromaLen = chromaW * chromaH;
|
||||
byte[] out = new byte[frameSize + chromaLen * 2];
|
||||
int uOffset = frameSize;
|
||||
int vOffset = frameSize + chromaLen;
|
||||
|
||||
for (int j = 0; j < height; j++) {
|
||||
for (int i = 0; i < width; i++) {
|
||||
int color = argb[j * width + i];
|
||||
int r = (color >> 16) & 0xff;
|
||||
int g = (color >> 8) & 0xff;
|
||||
int b = color & 0xff;
|
||||
int y = yFromRgb(r, g, b);
|
||||
out[j * width + i] = (byte) y;
|
||||
if ((j & 1) == 0 && (i & 1) == 0) {
|
||||
int u = uFromRgb(r, g, b);
|
||||
int v = vFromRgb(r, g, b);
|
||||
int cx = i >> 1;
|
||||
int cy = j >> 1;
|
||||
int cIndex = cy * chromaW + cx;
|
||||
out[uOffset + cIndex] = (byte) u;
|
||||
out[vOffset + cIndex] = (byte) v;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static byte[] toNv12(int[] argb, int width, int height) {
|
||||
int frameSize = width * height;
|
||||
int chromaW = (width + 1) / 2;
|
||||
int chromaH = (height + 1) / 2;
|
||||
byte[] out = new byte[frameSize + chromaW * chromaH * 2];
|
||||
int uvOffset = frameSize;
|
||||
|
||||
for (int j = 0; j < height; j++) {
|
||||
for (int i = 0; i < width; i++) {
|
||||
int color = argb[j * width + i];
|
||||
int r = (color >> 16) & 0xff;
|
||||
int g = (color >> 8) & 0xff;
|
||||
int b = color & 0xff;
|
||||
out[j * width + i] = (byte) yFromRgb(r, g, b);
|
||||
if ((j & 1) == 0 && (i & 1) == 0) {
|
||||
int u = uFromRgb(r, g, b);
|
||||
int v = vFromRgb(r, g, b);
|
||||
int cx = i >> 1;
|
||||
int cy = j >> 1;
|
||||
int uvIndex = uvOffset + (cy * chromaW + cx) * 2;
|
||||
out[uvIndex] = (byte) u;
|
||||
out[uvIndex + 1] = (byte) v;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static int yFromRgb(int r, int g, int b) {
|
||||
return clamp(((66 * r + 129 * g + 25 * b + 128) >> 8) + 16);
|
||||
}
|
||||
|
||||
private static int uFromRgb(int r, int g, int b) {
|
||||
return clamp(((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128);
|
||||
}
|
||||
|
||||
private static int vFromRgb(int r, int g, int b) {
|
||||
return clamp(((112 * r - 94 * g - 18 * b + 128) >> 8) + 128);
|
||||
}
|
||||
|
||||
private static int clamp(int v) {
|
||||
if (v < 0) {
|
||||
return 0;
|
||||
}
|
||||
if (v > 255) {
|
||||
return 255;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
@@ -24,14 +24,22 @@ public final class CastPacketFramer {
|
||||
return out;
|
||||
}
|
||||
|
||||
public static CastProtocol.Message unframe(byte[] wire) throws IOException {
|
||||
if (wire == null || wire.length < CastProtoHeader.HEADER_SIZE_V1 + 5) {
|
||||
public static CastProtoHeader readHeader(byte[] wire) throws IOException {
|
||||
if (wire == null || wire.length < CastProtoHeader.HEADER_SIZE_V1) {
|
||||
throw new IOException("Frame too short");
|
||||
}
|
||||
CastProtoHeader hdr = CastProtoHeader.decode(wire, 0, wire.length);
|
||||
if (!hdr.verifyChecksum()) {
|
||||
throw new IOException("Header checksum mismatch");
|
||||
}
|
||||
return hdr;
|
||||
}
|
||||
|
||||
public static CastProtocol.Message unframe(byte[] wire) throws IOException {
|
||||
if (wire == null || wire.length < CastProtoHeader.HEADER_SIZE_V1 + 5) {
|
||||
throw new IOException("Frame too short");
|
||||
}
|
||||
CastProtoHeader hdr = readHeader(wire);
|
||||
int innerOff = hdr.headerSize;
|
||||
if (wire.length < innerOff + 5) {
|
||||
throw new IOException("Missing message body");
|
||||
|
||||
@@ -255,7 +255,7 @@ public final class CastProtocol {
|
||||
return local.getTransport().equals(remote.getTransport());
|
||||
}
|
||||
|
||||
private static final byte NETWORK_STATS_VERSION = 3;
|
||||
private static final byte NETWORK_STATS_VERSION = 4;
|
||||
|
||||
public static byte[] networkStatsPayload(NetworkStatsSnapshot s) throws IOException {
|
||||
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||
@@ -283,6 +283,28 @@ public final class CastProtocol {
|
||||
dos.writeFloat(s.audioCapturePeakDb);
|
||||
dos.writeFloat(s.audioCaptureRmsDb);
|
||||
}
|
||||
if (NETWORK_STATS_VERSION >= 4) {
|
||||
dos.writeLong(s.udpDatagramsRx);
|
||||
dos.writeLong(s.udpDatagramsTx);
|
||||
dos.writeLong(s.udpDatagramsInvalid);
|
||||
dos.writeLong(s.udpFragmentsRx);
|
||||
dos.writeLong(s.udpFragmentsDuplicate);
|
||||
dos.writeLong(s.udpReassemblyOk);
|
||||
dos.writeLong(s.udpReassemblyLost);
|
||||
dos.writeLong(s.udpWireDropped);
|
||||
dos.writeLong(s.udpMalformed);
|
||||
dos.writeInt(s.droppedAudioFrames);
|
||||
dos.writeLong(s.recvVideoPackets);
|
||||
dos.writeLong(s.recvAudioPackets);
|
||||
dos.writeLong(s.recvVideoSkippedKey);
|
||||
dos.writeLong(s.recvVideoDecodeErrors);
|
||||
dos.writeLong(s.recvAudioDecodeErrors);
|
||||
dos.writeLong(s.recvAudioQueueDrops);
|
||||
dos.writeLong(s.recvDamagedVideoFrames);
|
||||
dos.writeUTF(s.protectionMode != null ? s.protectionMode : "NONE");
|
||||
dos.writeLong(s.fecStubPackets);
|
||||
dos.writeLong(s.nackStubRequests);
|
||||
}
|
||||
dos.flush();
|
||||
return bos.toByteArray();
|
||||
}
|
||||
@@ -315,6 +337,28 @@ public final class CastProtocol {
|
||||
s.audioCapturePeakDb = dis.readFloat();
|
||||
s.audioCaptureRmsDb = dis.readFloat();
|
||||
}
|
||||
if (ver >= 4 && dis.available() > 0) {
|
||||
s.udpDatagramsRx = dis.readLong();
|
||||
s.udpDatagramsTx = dis.readLong();
|
||||
s.udpDatagramsInvalid = dis.readLong();
|
||||
s.udpFragmentsRx = dis.readLong();
|
||||
s.udpFragmentsDuplicate = dis.readLong();
|
||||
s.udpReassemblyOk = dis.readLong();
|
||||
s.udpReassemblyLost = dis.readLong();
|
||||
s.udpWireDropped = dis.readLong();
|
||||
s.udpMalformed = dis.readLong();
|
||||
s.droppedAudioFrames = dis.readInt();
|
||||
s.recvVideoPackets = dis.readLong();
|
||||
s.recvAudioPackets = dis.readLong();
|
||||
s.recvVideoSkippedKey = dis.readLong();
|
||||
s.recvVideoDecodeErrors = dis.readLong();
|
||||
s.recvAudioDecodeErrors = dis.readLong();
|
||||
s.recvAudioQueueDrops = dis.readLong();
|
||||
s.recvDamagedVideoFrames = dis.readLong();
|
||||
s.protectionMode = dis.readUTF();
|
||||
s.fecStubPackets = dis.readLong();
|
||||
s.nackStubRequests = dis.readLong();
|
||||
}
|
||||
}
|
||||
}
|
||||
return s;
|
||||
@@ -324,7 +368,7 @@ public final class CastProtocol {
|
||||
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||
DataOutputStream dos = new DataOutputStream(bos);
|
||||
dos.writeInt(advice.suggestedBitrateKbps);
|
||||
dos.writeUTF(advice.suggestedTransport != null ? advice.suggestedTransport : CastConfig.TRANSPORT_TCP);
|
||||
dos.writeUTF(advice.suggestedTransport != null ? advice.suggestedTransport : CastConfig.DEFAULT_TRANSPORT);
|
||||
dos.writeByte(advice.switchTransport ? 1 : 0);
|
||||
dos.writeInt(advice.congestionLevel);
|
||||
dos.flush();
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.foxx.androidcast.network;
|
||||
|
||||
/**
|
||||
* Binds to the first non-zero session id on a connection and drops stale packets from other casts.
|
||||
*/
|
||||
public final class CastSessionGate {
|
||||
private static final int UNBOUND = Integer.MIN_VALUE;
|
||||
|
||||
private int boundSessionId = UNBOUND;
|
||||
|
||||
public boolean accept(CastProtoHeader header) {
|
||||
if (header == null) {
|
||||
return true;
|
||||
}
|
||||
int sid = header.sessionId;
|
||||
if (sid == 0) {
|
||||
return true;
|
||||
}
|
||||
if (boundSessionId == UNBOUND) {
|
||||
boundSessionId = sid;
|
||||
return true;
|
||||
}
|
||||
return boundSessionId == sid;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
boundSessionId = UNBOUND;
|
||||
}
|
||||
|
||||
public boolean isBound() {
|
||||
return boundSessionId != UNBOUND;
|
||||
}
|
||||
|
||||
public int getBoundSessionId() {
|
||||
return boundSessionId == UNBOUND ? 0 : boundSessionId;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.foxx.androidcast.network;
|
||||
|
||||
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
|
||||
@@ -7,6 +9,9 @@ import java.io.IOException;
|
||||
public interface CastTransport extends Closeable {
|
||||
/** Optional proto header context; ignored when null. */
|
||||
default void setFramingContext(CastFramingContext context) {}
|
||||
|
||||
/** Drops inbound wire packets whose session id does not match the active cast. */
|
||||
default void setInboundSessionGate(CastSessionGate gate) {}
|
||||
/** Server: bind and wait for first peer datagram / connection. */
|
||||
void listen(int port) throws IOException;
|
||||
|
||||
@@ -28,6 +33,11 @@ public interface CastTransport extends Closeable {
|
||||
|
||||
String getModeLabel();
|
||||
|
||||
/** Loss/damage counters when supported (UDP); empty stats otherwise. */
|
||||
default TransportLossStats getLossStats() {
|
||||
return new TransportLossStats();
|
||||
}
|
||||
|
||||
/** Server: drop the active client socket but keep the listen socket bound. */
|
||||
default void releaseConnection() {
|
||||
close();
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
package com.foxx.androidcast.network;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.network.quic.QuicCronetCastTransport;
|
||||
import com.foxx.androidcast.network.webrtc.WebRtcTransportFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public final class CastTransportFactory {
|
||||
private static Context appContext;
|
||||
|
||||
private CastTransportFactory() {}
|
||||
|
||||
public static void init(Context context) {
|
||||
appContext = context.getApplicationContext();
|
||||
}
|
||||
|
||||
public static CastTransport create(CastSettings settings) throws IOException {
|
||||
if (CastConfig.TRANSPORT_UDP.equals(settings.getTransport())) {
|
||||
String mode = settings.getTransport();
|
||||
if (CastConfig.TRANSPORT_QUIC.equals(mode)) {
|
||||
if (appContext == null) {
|
||||
throw new IOException("CastTransportFactory.init(context) required for QUIC");
|
||||
}
|
||||
return new QuicCronetCastTransport(appContext);
|
||||
}
|
||||
if (WebRtcTransportFactory.isTransportId(mode)) {
|
||||
return WebRtcTransportFactory.create();
|
||||
}
|
||||
if (CastConfig.TRANSPORT_UDP.equals(mode)) {
|
||||
return new UdpCastTransport();
|
||||
}
|
||||
return new TcpCastTransport();
|
||||
|
||||
@@ -5,6 +5,7 @@ import java.io.BufferedOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.ServerSocket;
|
||||
@@ -18,6 +19,7 @@ public class TcpCastTransport implements CastTransport {
|
||||
|
||||
private final Object sendLock = new Object();
|
||||
private CastFramingContext framingContext;
|
||||
private CastSessionGate inboundSessionGate;
|
||||
private final Object receiveLock = new Object();
|
||||
|
||||
private ServerSocket serverSocket;
|
||||
@@ -83,6 +85,11 @@ public class TcpCastTransport implements CastTransport {
|
||||
this.framingContext = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setInboundSessionGate(CastSessionGate gate) {
|
||||
this.inboundSessionGate = gate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(byte type, byte[] payload) throws IOException {
|
||||
synchronized (sendLock) {
|
||||
@@ -133,7 +140,83 @@ public class TcpCastTransport implements CastTransport {
|
||||
}
|
||||
|
||||
private CastProtocol.Message readFramedMessage() throws IOException {
|
||||
return CastPacketFramer.readFramedMessage(bufferedIn);
|
||||
if (inboundSessionGate == null) {
|
||||
return CastPacketFramer.readFramedMessage(bufferedIn);
|
||||
}
|
||||
byte[] magicBuf = readFully(bufferedIn, 4);
|
||||
if (magicBuf == null) {
|
||||
return null;
|
||||
}
|
||||
int magic = (magicBuf[0] & 0xff) | ((magicBuf[1] & 0xff) << 8)
|
||||
| ((magicBuf[2] & 0xff) << 16) | ((magicBuf[3] & 0xff) << 24);
|
||||
if (magic != CastProtoHeader.MAGIC) {
|
||||
throw new IOException("Bad magic on wire: 0x" + Integer.toHexString(magic));
|
||||
}
|
||||
byte[] restHdr = readFully(bufferedIn, CastProtoHeader.HEADER_SIZE_V1 - 4);
|
||||
if (restHdr == null) {
|
||||
return null;
|
||||
}
|
||||
byte[] headerBytes = new byte[CastProtoHeader.HEADER_SIZE_V1];
|
||||
System.arraycopy(magicBuf, 0, headerBytes, 0, 4);
|
||||
System.arraycopy(restHdr, 0, headerBytes, 4, restHdr.length);
|
||||
CastProtoHeader hdr = CastProtoHeader.decode(headerBytes, 0, headerBytes.length);
|
||||
if (!hdr.verifyChecksum()) {
|
||||
throw new IOException("Header checksum mismatch");
|
||||
}
|
||||
if (!inboundSessionGate.accept(hdr)) {
|
||||
skipMessageBody(in);
|
||||
return null;
|
||||
}
|
||||
byte type = (byte) in.read();
|
||||
if (type < 0) {
|
||||
return null;
|
||||
}
|
||||
byte[] lenBuf = readFully(bufferedIn, 4);
|
||||
if (lenBuf == null) {
|
||||
return null;
|
||||
}
|
||||
int len = ((lenBuf[0] & 0xff) << 24) | ((lenBuf[1] & 0xff) << 16)
|
||||
| ((lenBuf[2] & 0xff) << 8) | (lenBuf[3] & 0xff);
|
||||
if (len < 0 || len > 4 * 1024 * 1024) {
|
||||
throw new IOException("Invalid payload length: " + len);
|
||||
}
|
||||
byte[] payload = len == 0 ? new byte[0] : readFully(bufferedIn, len);
|
||||
if (payload == null && len > 0) {
|
||||
return null;
|
||||
}
|
||||
return new CastProtocol.Message(type, payload);
|
||||
}
|
||||
|
||||
private static void skipMessageBody(DataInputStream in) throws IOException {
|
||||
byte type = (byte) in.read();
|
||||
if (type < 0) {
|
||||
return;
|
||||
}
|
||||
byte[] lenBuf = readFully(in, 4);
|
||||
if (lenBuf == null) {
|
||||
return;
|
||||
}
|
||||
int len = ((lenBuf[0] & 0xff) << 24) | ((lenBuf[1] & 0xff) << 16)
|
||||
| ((lenBuf[2] & 0xff) << 8) | (lenBuf[3] & 0xff);
|
||||
if (len > 0 && len <= 4 * 1024 * 1024) {
|
||||
in.skipBytes(len);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] readFully(InputStream in, int n) throws IOException {
|
||||
byte[] buf = new byte[n];
|
||||
int off = 0;
|
||||
while (off < n) {
|
||||
int r = in.read(buf, off, n - off);
|
||||
if (r < 0) {
|
||||
if (off == 0) {
|
||||
return null;
|
||||
}
|
||||
throw new java.io.EOFException("Short read");
|
||||
}
|
||||
off += r;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
private boolean waitForHeader(int timeoutMs) throws IOException {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.foxx.androidcast.network;
|
||||
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.network.transport.StreamProtectionEngine;
|
||||
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||
import com.foxx.androidcast.network.transport.TransportStatsProvider;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
@@ -14,7 +17,7 @@ import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/** Best-effort message transport over UDP with fragmentation. */
|
||||
public class UdpCastTransport implements CastTransport {
|
||||
public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
private static final byte[] MAGIC = {'A', 'C', 'U', 'D'};
|
||||
/** magic(4) + type(1) + msgId(4) + fragIdx(2) + fragCnt(2) + totalLen(4) */
|
||||
private static final int HEADER = 17;
|
||||
@@ -26,7 +29,11 @@ public class UdpCastTransport implements CastTransport {
|
||||
private volatile boolean listening;
|
||||
|
||||
private final Map<Integer, Reassembly> pending = new ConcurrentHashMap<>();
|
||||
private final TransportLossStats lossStats = new TransportLossStats();
|
||||
private StreamProtectionEngine protectionEngine = StreamProtectionEngine.noop();
|
||||
private CastFramingContext framingContext;
|
||||
private CastSessionGate inboundSessionGate;
|
||||
private static final long REASSEMBLY_TIMEOUT_MS = 3_000;
|
||||
|
||||
public boolean isListening() {
|
||||
return listening && socket != null && !socket.isClosed();
|
||||
@@ -71,6 +78,24 @@ public class UdpCastTransport implements CastTransport {
|
||||
this.framingContext = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setInboundSessionGate(CastSessionGate gate) {
|
||||
this.inboundSessionGate = gate;
|
||||
}
|
||||
|
||||
/** Select protection stub (default none). FEC/NACK are not active yet. */
|
||||
public void setProtectionEngine(StreamProtectionEngine engine) {
|
||||
protectionEngine = engine != null ? engine : StreamProtectionEngine.noop();
|
||||
synchronized (lossStats) {
|
||||
lossStats.protectionMode = protectionEngine.mode();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransportLossStats getLossStats() {
|
||||
return lossStats.copy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(byte type, byte[] payload) throws IOException {
|
||||
if (peer == null) {
|
||||
@@ -85,6 +110,7 @@ public class UdpCastTransport implements CastTransport {
|
||||
} else {
|
||||
body = payload == null ? new byte[0] : payload;
|
||||
}
|
||||
body = protectionEngine.onOutboundPayload(type, body);
|
||||
if (body.length <= MAX_RELIABLE_BYTES) {
|
||||
sendOnce(wireType, body);
|
||||
sendOnce(wireType, body);
|
||||
@@ -103,6 +129,9 @@ public class UdpCastTransport implements CastTransport {
|
||||
byte[] packet = buildPacket(type, messageId, i, fragments, body, offset, len, body.length);
|
||||
DatagramPacket dp = new DatagramPacket(packet, packet.length, peer);
|
||||
socket.send(dp);
|
||||
synchronized (lossStats) {
|
||||
lossStats.datagramsSent++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,12 +142,16 @@ public class UdpCastTransport implements CastTransport {
|
||||
}
|
||||
long deadline = System.currentTimeMillis() + timeoutMs;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
expireStaleReassembly();
|
||||
int remaining = (int) Math.max(1, deadline - System.currentTimeMillis());
|
||||
socket.setSoTimeout(remaining);
|
||||
try {
|
||||
byte[] buf = new byte[CastConfig.UDP_CHUNK_SIZE + 64];
|
||||
DatagramPacket dp = new DatagramPacket(buf, buf.length);
|
||||
socket.receive(dp);
|
||||
synchronized (lossStats) {
|
||||
lossStats.datagramsReceived++;
|
||||
}
|
||||
if (peer == null) {
|
||||
peer = new InetSocketAddress(dp.getAddress(), dp.getPort());
|
||||
}
|
||||
@@ -132,15 +165,38 @@ public class UdpCastTransport implements CastTransport {
|
||||
return null;
|
||||
}
|
||||
|
||||
private void expireStaleReassembly() {
|
||||
long now = System.currentTimeMillis();
|
||||
for (Map.Entry<Integer, Reassembly> e : pending.entrySet()) {
|
||||
Reassembly r = e.getValue();
|
||||
if (r != null && now - r.startedMs > REASSEMBLY_TIMEOUT_MS) {
|
||||
pending.remove(e.getKey());
|
||||
synchronized (lossStats) {
|
||||
lossStats.reassemblyIncomplete++;
|
||||
}
|
||||
protectionEngine.onNackStub(e.getKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private CastProtocol.Message parsePacket(byte[] data, int length) throws IOException {
|
||||
if (length < HEADER) {
|
||||
synchronized (lossStats) {
|
||||
lossStats.datagramsInvalid++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (data[i] != MAGIC[i]) {
|
||||
synchronized (lossStats) {
|
||||
lossStats.datagramsInvalid++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
synchronized (lossStats) {
|
||||
lossStats.fragmentsReceived++;
|
||||
}
|
||||
byte type = data[4];
|
||||
int messageId = readInt(data, 5);
|
||||
int fragIndex = readShort(data, 9);
|
||||
@@ -152,32 +208,68 @@ public class UdpCastTransport implements CastTransport {
|
||||
|
||||
CastProtocol.Message raw;
|
||||
if (fragCount <= 1) {
|
||||
byte[] body;
|
||||
if (totalLen != payloadLen && totalLen > 0 && totalLen <= payloadLen) {
|
||||
byte[] trimmed = new byte[totalLen];
|
||||
System.arraycopy(chunk, 0, trimmed, 0, totalLen);
|
||||
raw = new CastProtocol.Message(type, trimmed);
|
||||
body = new byte[totalLen];
|
||||
System.arraycopy(chunk, 0, body, 0, totalLen);
|
||||
} else {
|
||||
raw = new CastProtocol.Message(type, chunk);
|
||||
body = chunk;
|
||||
}
|
||||
body = protectionEngine.onInboundPayload(type, body);
|
||||
raw = new CastProtocol.Message(type, body);
|
||||
return unwrapWire(raw);
|
||||
}
|
||||
|
||||
Reassembly r = pending.get(messageId);
|
||||
if (r == null || r.fragCount != fragCount || r.totalLen != totalLen) {
|
||||
Reassembly existing = pending.get(messageId);
|
||||
if (existing != null && (existing.fragCount != fragCount || existing.totalLen != totalLen)) {
|
||||
pending.remove(messageId);
|
||||
synchronized (lossStats) {
|
||||
lossStats.reassemblySuperseded++;
|
||||
}
|
||||
existing = null;
|
||||
}
|
||||
Reassembly r = existing;
|
||||
if (r == null) {
|
||||
r = new Reassembly(fragCount, totalLen);
|
||||
pending.put(messageId, r);
|
||||
}
|
||||
r.put(fragIndex, chunk, payloadLen);
|
||||
if (!r.put(fragIndex, chunk, payloadLen)) {
|
||||
synchronized (lossStats) {
|
||||
lossStats.fragmentsDuplicate++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!r.complete()) {
|
||||
return null;
|
||||
}
|
||||
pending.remove(messageId);
|
||||
return unwrapWire(new CastProtocol.Message(type, r.assemble()));
|
||||
synchronized (lossStats) {
|
||||
lossStats.reassemblyCompleted++;
|
||||
}
|
||||
byte[] assembled = r.assemble();
|
||||
assembled = protectionEngine.onInboundPayload(type, assembled);
|
||||
return unwrapWire(new CastProtocol.Message(type, assembled));
|
||||
}
|
||||
|
||||
private static CastProtocol.Message unwrapWire(CastProtocol.Message raw) throws IOException {
|
||||
private CastProtocol.Message unwrapWire(CastProtocol.Message raw) throws IOException {
|
||||
if (raw.type == CastProtocol.MSG_WIRE_PACKET) {
|
||||
return CastPacketFramer.unframe(raw.payload);
|
||||
if (inboundSessionGate != null) {
|
||||
CastProtoHeader hdr = CastPacketFramer.readHeader(raw.payload);
|
||||
if (!inboundSessionGate.accept(hdr)) {
|
||||
synchronized (lossStats) {
|
||||
lossStats.wirePacketsDropped++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return CastPacketFramer.unframe(raw.payload);
|
||||
} catch (IOException e) {
|
||||
synchronized (lossStats) {
|
||||
lossStats.malformedPackets++;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
@@ -242,6 +334,7 @@ public class UdpCastTransport implements CastTransport {
|
||||
private static final class Reassembly {
|
||||
final int fragCount;
|
||||
final int totalLen;
|
||||
final long startedMs;
|
||||
final byte[][] parts;
|
||||
final int[] sizes;
|
||||
int received;
|
||||
@@ -249,19 +342,21 @@ public class UdpCastTransport implements CastTransport {
|
||||
Reassembly(int fragCount, int totalLen) {
|
||||
this.fragCount = fragCount;
|
||||
this.totalLen = totalLen;
|
||||
this.startedMs = System.currentTimeMillis();
|
||||
this.parts = new byte[fragCount][];
|
||||
this.sizes = new int[fragCount];
|
||||
}
|
||||
|
||||
void put(int index, byte[] data, int len) {
|
||||
boolean put(int index, byte[] data, int len) {
|
||||
if (index < 0 || index >= fragCount || parts[index] != null) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
byte[] copy = new byte[len];
|
||||
System.arraycopy(data, 0, copy, 0, len);
|
||||
parts[index] = copy;
|
||||
sizes[index] = len;
|
||||
received++;
|
||||
return true;
|
||||
}
|
||||
|
||||
boolean complete() {
|
||||
|
||||
@@ -7,7 +7,7 @@ public class NetworkControlAdvice {
|
||||
public boolean sendStats;
|
||||
public boolean sendControl;
|
||||
public int suggestedBitrateKbps;
|
||||
public String suggestedTransport = CastConfig.TRANSPORT_TCP;
|
||||
public String suggestedTransport = CastConfig.DEFAULT_TRANSPORT;
|
||||
public boolean switchTransport;
|
||||
public int congestionLevel;
|
||||
|
||||
|
||||
@@ -27,6 +27,28 @@ public class NetworkStatsSnapshot {
|
||||
public float audioCapturePeakDb;
|
||||
public float audioCaptureRmsDb;
|
||||
|
||||
/** v4: UDP + app-layer loss (see {@link com.foxx.androidcast.network.transport.TransportLossStats}). */
|
||||
public long udpDatagramsRx;
|
||||
public long udpDatagramsTx;
|
||||
public long udpDatagramsInvalid;
|
||||
public long udpFragmentsRx;
|
||||
public long udpFragmentsDuplicate;
|
||||
public long udpReassemblyOk;
|
||||
public long udpReassemblyLost;
|
||||
public long udpWireDropped;
|
||||
public long udpMalformed;
|
||||
public int droppedAudioFrames;
|
||||
public long recvVideoPackets;
|
||||
public long recvAudioPackets;
|
||||
public long recvVideoSkippedKey;
|
||||
public long recvVideoDecodeErrors;
|
||||
public long recvAudioDecodeErrors;
|
||||
public long recvAudioQueueDrops;
|
||||
public long recvDamagedVideoFrames;
|
||||
public String protectionMode = "NONE";
|
||||
public long fecStubPackets;
|
||||
public long nackStubRequests;
|
||||
|
||||
public NetworkStatsSnapshot copy() {
|
||||
NetworkStatsSnapshot c = new NetworkStatsSnapshot();
|
||||
c.sequence = sequence;
|
||||
@@ -49,6 +71,26 @@ public class NetworkStatsSnapshot {
|
||||
c.audioCaptureAudibleBlocks = audioCaptureAudibleBlocks;
|
||||
c.audioCapturePeakDb = audioCapturePeakDb;
|
||||
c.audioCaptureRmsDb = audioCaptureRmsDb;
|
||||
c.udpDatagramsRx = udpDatagramsRx;
|
||||
c.udpDatagramsTx = udpDatagramsTx;
|
||||
c.udpDatagramsInvalid = udpDatagramsInvalid;
|
||||
c.udpFragmentsRx = udpFragmentsRx;
|
||||
c.udpFragmentsDuplicate = udpFragmentsDuplicate;
|
||||
c.udpReassemblyOk = udpReassemblyOk;
|
||||
c.udpReassemblyLost = udpReassemblyLost;
|
||||
c.udpWireDropped = udpWireDropped;
|
||||
c.udpMalformed = udpMalformed;
|
||||
c.droppedAudioFrames = droppedAudioFrames;
|
||||
c.recvVideoPackets = recvVideoPackets;
|
||||
c.recvAudioPackets = recvAudioPackets;
|
||||
c.recvVideoSkippedKey = recvVideoSkippedKey;
|
||||
c.recvVideoDecodeErrors = recvVideoDecodeErrors;
|
||||
c.recvAudioDecodeErrors = recvAudioDecodeErrors;
|
||||
c.recvAudioQueueDrops = recvAudioQueueDrops;
|
||||
c.recvDamagedVideoFrames = recvDamagedVideoFrames;
|
||||
c.protectionMode = protectionMode;
|
||||
c.fecStubPackets = fecStubPackets;
|
||||
c.nackStubRequests = nackStubRequests;
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
|
||||
outbound.sendStats = nowMs - lastStatsMs >= STATS_INTERVAL_MS;
|
||||
outbound.sendControl = false;
|
||||
outbound.suggestedBitrateKbps = 0;
|
||||
outbound.suggestedTransport = CastConfig.TRANSPORT_TCP;
|
||||
outbound.suggestedTransport = CastConfig.DEFAULT_TRANSPORT;
|
||||
outbound.switchTransport = false;
|
||||
outbound.congestionLevel = 0;
|
||||
if (outbound.sendStats) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.foxx.androidcast.network.quic;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.google.android.gms.net.CronetProviderInstaller;
|
||||
import com.google.android.gms.tasks.Tasks;
|
||||
|
||||
import org.chromium.net.CronetEngine;
|
||||
import org.chromium.net.ExperimentalCronetEngine;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/** Shared Cronet engine with QUIC / HTTP3 enabled for experimental cast transport. */
|
||||
public final class CronetEngineProvider {
|
||||
private static final AtomicReference<CronetEngine> ENGINE = new AtomicReference<>();
|
||||
|
||||
private CronetEngineProvider() {}
|
||||
|
||||
public static CronetEngine getEngine(Context context) throws Exception {
|
||||
CronetEngine existing = ENGINE.get();
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
synchronized (CronetEngineProvider.class) {
|
||||
existing = ENGINE.get();
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
Context app = context.getApplicationContext();
|
||||
Tasks.await(CronetProviderInstaller.installProvider(app), 30, TimeUnit.SECONDS);
|
||||
ExperimentalCronetEngine.Builder builder =
|
||||
new ExperimentalCronetEngine.Builder(app)
|
||||
.enableQuic(true)
|
||||
.enableHttp2(true)
|
||||
.setStoragePath(app.getCacheDir().getAbsolutePath())
|
||||
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, 256 * 1024);
|
||||
CronetEngine built = builder.build();
|
||||
ENGINE.set(built);
|
||||
return built;
|
||||
}
|
||||
}
|
||||
|
||||
public static void shutdown() {
|
||||
CronetEngine e = ENGINE.getAndSet(null);
|
||||
if (e != null) {
|
||||
e.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.foxx.androidcast.network.quic;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.network.CastFramingContext;
|
||||
import com.foxx.androidcast.network.CastProtocol;
|
||||
import com.foxx.androidcast.network.CastTransport;
|
||||
import com.foxx.androidcast.network.TcpCastTransport;
|
||||
import com.foxx.androidcast.network.UdpCastTransport;
|
||||
|
||||
import org.chromium.net.CronetEngine;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Experimental QUIC cast transport (Cronet stack).
|
||||
* <p>
|
||||
* Cronet on Android is a QUIC/HTTP3 <em>client</em> only. This transport uses:
|
||||
* <ul>
|
||||
* <li>{@link CronetEngine} with QUIC enabled (shared via {@link CronetEngineProvider})</li>
|
||||
* <li>Framed cast messages on {@link CastConfig#QUIC_PORT} over UDP (same as legacy ACUD framing)</li>
|
||||
* <li>TCP fallback on the same port if UDP bind fails</li>
|
||||
* </ul>
|
||||
* A future revision can move the data plane to Cronet {@code BidirectionalStream} once an HTTP/3
|
||||
* terminator is available on the receiver.
|
||||
*/
|
||||
public final class QuicCronetCastTransport implements CastTransport {
|
||||
private static final String TAG = "QuicCronetTransport";
|
||||
|
||||
private final Context appContext;
|
||||
private final UdpCastTransport udp = new UdpCastTransport();
|
||||
private final TcpCastTransport tcpFallback = new TcpCastTransport();
|
||||
private final AtomicBoolean usingTcp = new AtomicBoolean(false);
|
||||
private CronetEngine engine;
|
||||
|
||||
public QuicCronetCastTransport(Context context) {
|
||||
this.appContext = context.getApplicationContext();
|
||||
}
|
||||
|
||||
private CastTransport active() {
|
||||
return usingTcp.get() ? tcpFallback : udp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setFramingContext(CastFramingContext context) {
|
||||
udp.setFramingContext(context);
|
||||
tcpFallback.setFramingContext(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setInboundSessionGate(com.foxx.androidcast.network.CastSessionGate gate) {
|
||||
udp.setInboundSessionGate(gate);
|
||||
tcpFallback.setInboundSessionGate(gate);
|
||||
}
|
||||
|
||||
private void ensureCronet() {
|
||||
if (engine != null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
engine = CronetEngineProvider.getEngine(appContext);
|
||||
Log.i(TAG, "Cronet engine ready (QUIC/HTTP3 enabled for experimental transport)");
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Cronet engine unavailable: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void prepareListen(int port) throws IOException {
|
||||
ensureCronet();
|
||||
int quicPort = port > 0 ? port : CastConfig.QUIC_PORT;
|
||||
try {
|
||||
udp.prepareListen(quicPort);
|
||||
usingTcp.set(false);
|
||||
Log.i(TAG, "QUIC listen UDP :" + quicPort);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "QUIC UDP listen failed, TCP fallback :" + quicPort, e);
|
||||
tcpFallback.prepareListen(quicPort);
|
||||
usingTcp.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void acceptClient() throws IOException {
|
||||
active().acceptClient();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void listen(int port) throws IOException {
|
||||
prepareListen(port);
|
||||
acceptClient();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connect(String host, int port) throws IOException {
|
||||
ensureCronet();
|
||||
int quicPort = port > 0 ? port : CastConfig.QUIC_PORT;
|
||||
try {
|
||||
udp.connect(host, quicPort);
|
||||
usingTcp.set(false);
|
||||
Log.i(TAG, "QUIC connect UDP " + host + ":" + quicPort);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "QUIC UDP connect failed, TCP fallback", e);
|
||||
tcpFallback.connect(host, quicPort);
|
||||
usingTcp.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(byte type, byte[] payload) throws IOException {
|
||||
active().send(type, payload);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CastProtocol.Message receive(int timeoutMs) throws IOException {
|
||||
return active().receive(timeoutMs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getModeLabel() {
|
||||
String base = usingTcp.get() ? "QUIC(exp/TCP)" : "QUIC(exp/UDP)";
|
||||
return engine != null ? base + "+Cronet" : base;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void releaseConnection() {
|
||||
active().releaseConnection();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
udp.close();
|
||||
tcpFallback.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.foxx.androidcast.network.transport;
|
||||
|
||||
/**
|
||||
* Hook for future FEC / NACK over UDP. Current builds use {@link #noop()} only.
|
||||
*/
|
||||
public interface StreamProtectionEngine {
|
||||
StreamProtectionMode mode();
|
||||
|
||||
/** Called before UDP payload is fragmented (outbound). */
|
||||
byte[] onOutboundPayload(byte type, byte[] payload);
|
||||
|
||||
/** Called when a reassembled UDP message is ready (inbound). */
|
||||
byte[] onInboundPayload(byte type, byte[] payload);
|
||||
|
||||
/** Future: process FEC repair block (stub). */
|
||||
default void onFecBlockStub(byte[] block) {
|
||||
}
|
||||
|
||||
/** Future: sender handles NACK for messageId (stub). */
|
||||
default void onNackStub(int messageId) {
|
||||
}
|
||||
|
||||
static StreamProtectionEngine noop() {
|
||||
return new StreamProtectionEngine() {
|
||||
@Override
|
||||
public StreamProtectionMode mode() {
|
||||
return StreamProtectionMode.NONE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] onOutboundPayload(byte type, byte[] payload) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] onInboundPayload(byte type, byte[] payload) {
|
||||
return payload;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Placeholder for upcoming FEC — counts invocations only. */
|
||||
static StreamProtectionEngine fecStub(TransportLossStats stats) {
|
||||
return new StreamProtectionEngine() {
|
||||
@Override
|
||||
public StreamProtectionMode mode() {
|
||||
return StreamProtectionMode.FEC_STUB;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] onOutboundPayload(byte type, byte[] payload) {
|
||||
if (stats != null) {
|
||||
synchronized (stats) {
|
||||
stats.fecPacketsStub++;
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] onInboundPayload(byte type, byte[] payload) {
|
||||
return payload;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Placeholder for upcoming NACK — no retransmit yet. */
|
||||
static StreamProtectionEngine nackStub(TransportLossStats stats) {
|
||||
return new StreamProtectionEngine() {
|
||||
@Override
|
||||
public StreamProtectionMode mode() {
|
||||
return StreamProtectionMode.NACK_STUB;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] onOutboundPayload(byte type, byte[] payload) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] onInboundPayload(byte type, byte[] payload) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNackStub(int messageId) {
|
||||
if (stats != null) {
|
||||
synchronized (stats) {
|
||||
stats.nackRequestsStub++;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.foxx.androidcast.network.transport;
|
||||
|
||||
/** Planned LAN cast reliability features (stubs until implemented). */
|
||||
public enum StreamProtectionMode {
|
||||
NONE,
|
||||
/** Forward-error correction over UDP (not implemented). */
|
||||
FEC_STUB,
|
||||
/** Negative acknowledgement / retransmit requests (not implemented). */
|
||||
NACK_STUB;
|
||||
|
||||
public boolean isStub() {
|
||||
return this == FEC_STUB || this == NACK_STUB;
|
||||
}
|
||||
|
||||
public String overlayLabel() {
|
||||
switch (this) {
|
||||
case FEC_STUB:
|
||||
return "FEC (stub)";
|
||||
case NACK_STUB:
|
||||
return "NACK (stub)";
|
||||
case NONE:
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.foxx.androidcast.network.transport;
|
||||
|
||||
/**
|
||||
* Per-transport loss / damage counters (UDP datagrams, reassembly, app-layer drops).
|
||||
* Thread-safe increments; copy for stats exchange.
|
||||
*/
|
||||
public final class TransportLossStats {
|
||||
public long datagramsSent;
|
||||
public long datagramsReceived;
|
||||
public long datagramsInvalid;
|
||||
public long fragmentsReceived;
|
||||
public long fragmentsDuplicate;
|
||||
public long reassemblyCompleted;
|
||||
public long reassemblyIncomplete;
|
||||
public long reassemblySuperseded;
|
||||
public long wirePacketsDropped;
|
||||
public long malformedPackets;
|
||||
|
||||
public int sendDroppedVideoP;
|
||||
public int sendDroppedAudio;
|
||||
public int sendQueueDepth;
|
||||
|
||||
public long recvVideoPackets;
|
||||
public long recvAudioPackets;
|
||||
public long recvVideoSkippedAwaitingKey;
|
||||
public long recvVideoDecodeErrors;
|
||||
public long recvAudioDecodeErrors;
|
||||
public long recvAudioQueueDrops;
|
||||
public long recvDamagedVideoFrames;
|
||||
|
||||
public StreamProtectionMode protectionMode = StreamProtectionMode.NONE;
|
||||
public long fecPacketsStub;
|
||||
public long nackRequestsStub;
|
||||
public long nackRetransmitsStub;
|
||||
|
||||
public synchronized void addFrom(TransportLossStats other) {
|
||||
if (other == null) {
|
||||
return;
|
||||
}
|
||||
datagramsSent += other.datagramsSent;
|
||||
datagramsReceived += other.datagramsReceived;
|
||||
datagramsInvalid += other.datagramsInvalid;
|
||||
fragmentsReceived += other.fragmentsReceived;
|
||||
fragmentsDuplicate += other.fragmentsDuplicate;
|
||||
reassemblyCompleted += other.reassemblyCompleted;
|
||||
reassemblyIncomplete += other.reassemblyIncomplete;
|
||||
reassemblySuperseded += other.reassemblySuperseded;
|
||||
wirePacketsDropped += other.wirePacketsDropped;
|
||||
malformedPackets += other.malformedPackets;
|
||||
sendDroppedVideoP += other.sendDroppedVideoP;
|
||||
sendDroppedAudio += other.sendDroppedAudio;
|
||||
recvVideoPackets += other.recvVideoPackets;
|
||||
recvAudioPackets += other.recvAudioPackets;
|
||||
recvVideoSkippedAwaitingKey += other.recvVideoSkippedAwaitingKey;
|
||||
recvVideoDecodeErrors += other.recvVideoDecodeErrors;
|
||||
recvAudioDecodeErrors += other.recvAudioDecodeErrors;
|
||||
recvAudioQueueDrops += other.recvAudioQueueDrops;
|
||||
recvDamagedVideoFrames += other.recvDamagedVideoFrames;
|
||||
fecPacketsStub += other.fecPacketsStub;
|
||||
nackRequestsStub += other.nackRequestsStub;
|
||||
nackRetransmitsStub += other.nackRetransmitsStub;
|
||||
}
|
||||
|
||||
public synchronized TransportLossStats copy() {
|
||||
TransportLossStats c = new TransportLossStats();
|
||||
c.datagramsSent = datagramsSent;
|
||||
c.datagramsReceived = datagramsReceived;
|
||||
c.datagramsInvalid = datagramsInvalid;
|
||||
c.fragmentsReceived = fragmentsReceived;
|
||||
c.fragmentsDuplicate = fragmentsDuplicate;
|
||||
c.reassemblyCompleted = reassemblyCompleted;
|
||||
c.reassemblyIncomplete = reassemblyIncomplete;
|
||||
c.reassemblySuperseded = reassemblySuperseded;
|
||||
c.wirePacketsDropped = wirePacketsDropped;
|
||||
c.malformedPackets = malformedPackets;
|
||||
c.sendDroppedVideoP = sendDroppedVideoP;
|
||||
c.sendDroppedAudio = sendDroppedAudio;
|
||||
c.sendQueueDepth = sendQueueDepth;
|
||||
c.recvVideoPackets = recvVideoPackets;
|
||||
c.recvAudioPackets = recvAudioPackets;
|
||||
c.recvVideoSkippedAwaitingKey = recvVideoSkippedAwaitingKey;
|
||||
c.recvVideoDecodeErrors = recvVideoDecodeErrors;
|
||||
c.recvAudioDecodeErrors = recvAudioDecodeErrors;
|
||||
c.recvAudioQueueDrops = recvAudioQueueDrops;
|
||||
c.recvDamagedVideoFrames = recvDamagedVideoFrames;
|
||||
c.protectionMode = protectionMode;
|
||||
c.fecPacketsStub = fecPacketsStub;
|
||||
c.nackRequestsStub = nackRequestsStub;
|
||||
c.nackRetransmitsStub = nackRetransmitsStub;
|
||||
return c;
|
||||
}
|
||||
|
||||
/** Estimated UDP loss: incomplete reassembly + invalid, not double-counting fragments. */
|
||||
public synchronized long estimatedUdpLossEvents() {
|
||||
return reassemblyIncomplete + reassemblySuperseded + datagramsInvalid + malformedPackets;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.foxx.androidcast.network.transport;
|
||||
|
||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
|
||||
/** Copies {@link TransportLossStats} into {@link NetworkStatsSnapshot} for overlay / heartbeat. */
|
||||
public final class TransportLossStatsBridge {
|
||||
private TransportLossStatsBridge() {}
|
||||
|
||||
public static void applyTo(NetworkStatsSnapshot s, TransportLossStats loss) {
|
||||
if (s == null || loss == null) {
|
||||
return;
|
||||
}
|
||||
synchronized (loss) {
|
||||
s.udpDatagramsRx = loss.datagramsReceived;
|
||||
s.udpDatagramsTx = loss.datagramsSent;
|
||||
s.udpDatagramsInvalid = loss.datagramsInvalid;
|
||||
s.udpFragmentsRx = loss.fragmentsReceived;
|
||||
s.udpFragmentsDuplicate = loss.fragmentsDuplicate;
|
||||
s.udpReassemblyOk = loss.reassemblyCompleted;
|
||||
s.udpReassemblyLost = loss.reassemblyIncomplete + loss.reassemblySuperseded;
|
||||
s.udpWireDropped = loss.wirePacketsDropped;
|
||||
s.udpMalformed = loss.malformedPackets;
|
||||
s.droppedPFrames = Math.max(s.droppedPFrames, loss.sendDroppedVideoP);
|
||||
s.droppedAudioFrames = loss.sendDroppedAudio;
|
||||
s.encodeQueueDepth = Math.max(s.encodeQueueDepth, loss.sendQueueDepth);
|
||||
s.recvVideoPackets = loss.recvVideoPackets;
|
||||
s.recvAudioPackets = loss.recvAudioPackets;
|
||||
s.recvVideoSkippedKey = loss.recvVideoSkippedAwaitingKey;
|
||||
s.recvVideoDecodeErrors = loss.recvVideoDecodeErrors;
|
||||
s.recvAudioDecodeErrors = loss.recvAudioDecodeErrors;
|
||||
s.recvAudioQueueDrops = loss.recvAudioQueueDrops;
|
||||
s.recvDamagedVideoFrames = loss.recvDamagedVideoFrames;
|
||||
s.protectionMode = loss.protectionMode.name();
|
||||
s.fecStubPackets = loss.fecPacketsStub;
|
||||
s.nackStubRequests = loss.nackRequestsStub;
|
||||
long lossEvents = loss.estimatedUdpLossEvents();
|
||||
if (loss.datagramsReceived > 0 && lossEvents > 0) {
|
||||
s.lossRatio = Math.min(1f, lossEvents / (float) loss.datagramsReceived);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.foxx.androidcast.network.transport;
|
||||
|
||||
/** Transports that expose {@link TransportLossStats}. */
|
||||
public interface TransportStatsProvider {
|
||||
TransportLossStats getLossStats();
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.foxx.androidcast.network.webrtc;
|
||||
|
||||
import com.foxx.androidcast.network.CastProtocol;
|
||||
import com.foxx.androidcast.network.CastTransport;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Future WebRTC transport (libwebrtc or pion-backed). Not wired yet — use {@link #createStub()}.
|
||||
*/
|
||||
public interface WebRtcCastTransport extends CastTransport {
|
||||
|
||||
/** Signaling / ICE configuration for upcoming integration. */
|
||||
interface SignalingConfig {
|
||||
String getSignalingUrl();
|
||||
|
||||
String[] getIceServers();
|
||||
}
|
||||
|
||||
/** Attach when integrating real PeerConnection / DataChannel. */
|
||||
void attachPeer(WebRtcPeerAdapter peer);
|
||||
|
||||
/**
|
||||
* Placeholder until libwebrtc or pion transport is connected.
|
||||
*/
|
||||
static WebRtcCastTransport createStub() {
|
||||
return new WebRtcCastTransportStub();
|
||||
}
|
||||
|
||||
final class WebRtcCastTransportStub implements WebRtcCastTransport {
|
||||
@Override
|
||||
public void attachPeer(WebRtcPeerAdapter peer) {}
|
||||
|
||||
@Override
|
||||
public void listen(int port) throws IOException {
|
||||
throw new UnsupportedOperationException("WebRTC transport is not implemented yet");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connect(String host, int port) throws IOException {
|
||||
throw new UnsupportedOperationException("WebRTC transport is not implemented yet");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(byte type, byte[] payload) throws IOException {
|
||||
throw new UnsupportedOperationException("WebRTC transport is not implemented yet");
|
||||
}
|
||||
|
||||
@Override
|
||||
public CastProtocol.Message receive(int timeoutMs) throws IOException {
|
||||
throw new UnsupportedOperationException("WebRTC transport is not implemented yet");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getModeLabel() {
|
||||
return "WebRTC (stub)";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.foxx.androidcast.network.webrtc;
|
||||
|
||||
/**
|
||||
* Abstraction over future {@code org.webrtc.PeerConnection} or pion peer APIs.
|
||||
* Implementations will map cast frames to SCTP DataChannel or encoded tracks.
|
||||
*/
|
||||
public interface WebRtcPeerAdapter {
|
||||
|
||||
interface Observer {
|
||||
void onIceCandidate(String sdpMid, int sdpMLineIndex, String candidate);
|
||||
|
||||
void onConnectionStateChanged(WebRtcConnectionState state);
|
||||
|
||||
void onDataChannelMessage(byte[] payload);
|
||||
|
||||
void onEncodedVideoFrame(long ptsUs, boolean keyFrame, byte[] data);
|
||||
}
|
||||
|
||||
enum WebRtcConnectionState {
|
||||
NEW, CONNECTING, CONNECTED, DISCONNECTED, FAILED, CLOSED
|
||||
}
|
||||
|
||||
void setObserver(Observer observer);
|
||||
|
||||
void createOffer();
|
||||
|
||||
void createAnswer(String remoteDescription);
|
||||
|
||||
void setRemoteDescription(String sdp, boolean isOffer);
|
||||
|
||||
void addIceCandidate(String sdpMid, int sdpMLineIndex, String candidate);
|
||||
|
||||
/** Send opaque cast protocol bytes on a labeled DataChannel. */
|
||||
void sendDataChannel(byte[] payload);
|
||||
|
||||
void close();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.foxx.androidcast.network.webrtc;
|
||||
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.network.CastTransport;
|
||||
|
||||
/**
|
||||
* Factory for WebRTC-backed cast transport (stub today; swap implementation for libwebrtc / pion).
|
||||
*/
|
||||
public final class WebRtcTransportFactory {
|
||||
private WebRtcTransportFactory() {}
|
||||
|
||||
public static boolean isTransportId(String transport) {
|
||||
return CastConfig.TRANSPORT_WEBRTC.equals(transport);
|
||||
}
|
||||
|
||||
public static CastTransport create() {
|
||||
return WebRtcCastTransport.createStub();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import android.media.AudioManager;
|
||||
import android.media.AudioTrack;
|
||||
import android.media.MediaCodec;
|
||||
import android.media.MediaFormat;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.HandlerThread;
|
||||
import android.util.Log;
|
||||
@@ -36,6 +37,7 @@ public class AudioDecoder {
|
||||
public long pcmSilentBlocks;
|
||||
public long pcmAudibleBlocks;
|
||||
public long decodeErrors;
|
||||
public long queueDrops;
|
||||
public float lastPeak;
|
||||
public float lastRms;
|
||||
}
|
||||
@@ -43,7 +45,8 @@ public class AudioDecoder {
|
||||
private final Context appContext;
|
||||
private final HandlerThread workerThread = new HandlerThread("AudioDecoderCtrl");
|
||||
private Handler worker;
|
||||
private final LinkedBlockingQueue<PendingFrame> inputQueue = new LinkedBlockingQueue<>(128);
|
||||
private final LinkedBlockingQueue<PendingFrame> inputQueue = new LinkedBlockingQueue<>(256);
|
||||
private long playbackEpochUs = -1;
|
||||
private final Stats stats = new Stats();
|
||||
|
||||
private MediaCodec codec;
|
||||
@@ -130,6 +133,7 @@ public class AudioDecoder {
|
||||
|
||||
private void startDrainThread() {
|
||||
drainThread = new Thread(this::drainLoop, "AudioDecoderDrain");
|
||||
drainThread.setPriority(Thread.MAX_PRIORITY - 2);
|
||||
drainThread.start();
|
||||
}
|
||||
|
||||
@@ -138,7 +142,7 @@ public class AudioDecoder {
|
||||
? AudioFormat.CHANNEL_OUT_STEREO
|
||||
: AudioFormat.CHANNEL_OUT_MONO;
|
||||
int minBuf = AudioTrack.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT);
|
||||
int bufSize = Math.max(minBuf * 8, 65536);
|
||||
int bufSize = Math.max(minBuf * 16, 131072);
|
||||
track = new AudioTrack.Builder()
|
||||
.setAudioAttributes(new AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
@@ -164,8 +168,11 @@ public class AudioDecoder {
|
||||
}
|
||||
stats.framesQueued++;
|
||||
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
|
||||
stats.queueDrops++;
|
||||
inputQueue.poll();
|
||||
inputQueue.offer(new PendingFrame(ptsUs, data.clone()));
|
||||
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
|
||||
stats.queueDrops++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +248,8 @@ public class AudioDecoder {
|
||||
} else {
|
||||
stats.pcmAudibleBlocks++;
|
||||
}
|
||||
int written = writePcm(pcm);
|
||||
pacePlayback(info.presentationTimeUs);
|
||||
int written = writePcmBlocking(pcm);
|
||||
if (written > 0) {
|
||||
stats.pcmBytesWritten += written;
|
||||
if (renderCallback != null) {
|
||||
@@ -253,16 +261,42 @@ public class AudioDecoder {
|
||||
}
|
||||
}
|
||||
|
||||
private int writePcm(byte[] pcm) {
|
||||
private void pacePlayback(long ptsUs) {
|
||||
long nowUs = System.nanoTime() / 1000L;
|
||||
if (playbackEpochUs < 0) {
|
||||
playbackEpochUs = nowUs - ptsUs;
|
||||
return;
|
||||
}
|
||||
long dueUs = playbackEpochUs + ptsUs;
|
||||
long aheadUs = dueUs - nowUs;
|
||||
if (aheadUs > 120_000L) {
|
||||
try {
|
||||
Thread.sleep(aheadUs / 1000L, (int) ((aheadUs % 1000L) * 1000L));
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int writePcmBlocking(byte[] pcm) {
|
||||
int total = 0;
|
||||
int offset = 0;
|
||||
while (offset < pcm.length) {
|
||||
int idle = 0;
|
||||
while (offset < pcm.length && idle < 80) {
|
||||
int w = track.write(pcm, offset, pcm.length - offset);
|
||||
if (w <= 0) {
|
||||
if (w > 0) {
|
||||
total += w;
|
||||
offset += w;
|
||||
idle = 0;
|
||||
continue;
|
||||
}
|
||||
idle++;
|
||||
try {
|
||||
Thread.sleep(2);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
total += w;
|
||||
offset += w;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
@@ -275,7 +309,11 @@ public class AudioDecoder {
|
||||
}
|
||||
}
|
||||
|
||||
public void release() {
|
||||
/** Stops playback immediately and drops queued AAC (sender ended cast). */
|
||||
public void flushAndStop() {
|
||||
draining = false;
|
||||
configured = false;
|
||||
inputQueue.clear();
|
||||
final Object lock = new Object();
|
||||
worker.post(() -> {
|
||||
releaseOnWorker();
|
||||
@@ -285,21 +323,26 @@ public class AudioDecoder {
|
||||
});
|
||||
synchronized (lock) {
|
||||
try {
|
||||
lock.wait(2000);
|
||||
lock.wait(500);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void release() {
|
||||
flushAndStop();
|
||||
}
|
||||
|
||||
private void releaseOnWorker() {
|
||||
draining = false;
|
||||
configured = false;
|
||||
playbackEpochUs = -1;
|
||||
inputQueue.clear();
|
||||
if (drainThread != null) {
|
||||
drainThread.interrupt();
|
||||
try {
|
||||
drainThread.join(1500);
|
||||
drainThread.join(400);
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
@@ -315,12 +358,26 @@ public class AudioDecoder {
|
||||
}
|
||||
if (track != null) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
track.pause();
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
track.flush();
|
||||
}
|
||||
track.stop();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
track.release();
|
||||
track = null;
|
||||
}
|
||||
abandonAudioFocus();
|
||||
}
|
||||
|
||||
private void abandonAudioFocus() {
|
||||
AudioManager am = (AudioManager) appContext.getSystemService(Context.AUDIO_SERVICE);
|
||||
if (am != null) {
|
||||
am.abandonAudioFocus(null);
|
||||
}
|
||||
}
|
||||
|
||||
public void releaseAndQuit() {
|
||||
|
||||
@@ -22,7 +22,11 @@ import com.foxx.androidcast.R;
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.diagnostics.CastDiagnosticsFormatter;
|
||||
import com.foxx.androidcast.discovery.DiscoveryManager;
|
||||
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||
import com.foxx.androidcast.network.transport.TransportLossStatsBridge;
|
||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
import com.foxx.androidcast.stats.SessionStatsRecorder;
|
||||
import com.foxx.androidcast.stats.SessionStatsStore;
|
||||
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||
|
||||
import java.util.Arrays;
|
||||
@@ -80,7 +84,10 @@ public class ReceiverCastService extends Service {
|
||||
private CastSettings remoteCastSettings;
|
||||
private NetworkStatsSnapshot lastLocalStats;
|
||||
private boolean streamIdle;
|
||||
private volatile boolean castEnded;
|
||||
private long lastVideoFrameMs;
|
||||
private final TransportLossStats transportLoss = new TransportLossStats();
|
||||
private SessionStatsRecorder sessionStatsRecorder;
|
||||
|
||||
private final Runnable idleWatchdog = new Runnable() {
|
||||
@Override
|
||||
@@ -136,6 +143,7 @@ public class ReceiverCastService extends Service {
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
CastNotifications.createChannels(this);
|
||||
SessionStatsStore.pruneOnAppStart(this);
|
||||
mainHandler.post(idleWatchdog);
|
||||
}
|
||||
|
||||
@@ -175,7 +183,11 @@ public class ReceiverCastService extends Service {
|
||||
|
||||
private void restartListeningLocked(String pin) {
|
||||
stopSessionOnly();
|
||||
castEnded = false;
|
||||
resetStreamState();
|
||||
pendingWidth = 0;
|
||||
pendingHeight = 0;
|
||||
playbackLaunched = false;
|
||||
phase = Phase.LISTENING;
|
||||
acquireWakeLock();
|
||||
startForeground(CastNotifications.ID_RECEIVER,
|
||||
@@ -220,10 +232,10 @@ public class ReceiverCastService extends Service {
|
||||
|
||||
@Override
|
||||
public void onVideoFrame(long ptsUs, boolean keyFrame, byte[] data) {
|
||||
if (reconfiguring) {
|
||||
return;
|
||||
}
|
||||
if (awaitingKeyframe && !keyFrame) {
|
||||
synchronized (transportLoss) {
|
||||
transportLoss.recvVideoSkippedAwaitingKey++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (keyFrame) {
|
||||
@@ -237,7 +249,10 @@ public class ReceiverCastService extends Service {
|
||||
onStreamResumedAfterIdle();
|
||||
}
|
||||
streamMetrics.onVideoPacket(keyFrame, frameBytes);
|
||||
if (videoDecoder != null && !reconfiguring) {
|
||||
synchronized (transportLoss) {
|
||||
transportLoss.recvVideoPackets++;
|
||||
}
|
||||
if (videoDecoder != null) {
|
||||
videoDecoder.queueFrame(ptsUs, keyFrame, data);
|
||||
}
|
||||
});
|
||||
@@ -271,6 +286,9 @@ public class ReceiverCastService extends Service {
|
||||
|
||||
@Override
|
||||
public void onAudioFrame(long ptsUs, byte[] data) {
|
||||
if (castEnded) {
|
||||
return;
|
||||
}
|
||||
final int frameBytes = data != null ? data.length : 0;
|
||||
final boolean accept;
|
||||
synchronized (ReceiverCastService.this) {
|
||||
@@ -279,7 +297,17 @@ public class ReceiverCastService extends Service {
|
||||
if (accept && audioDecoder != null) {
|
||||
audioDecoder.queueFrame(ptsUs, data);
|
||||
}
|
||||
mainHandler.post(() -> streamMetrics.onAudioPacket(frameBytes));
|
||||
mainHandler.post(() -> {
|
||||
streamMetrics.onAudioPacket(frameBytes);
|
||||
synchronized (transportLoss) {
|
||||
transportLoss.recvAudioPackets++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCastEnded() {
|
||||
mainHandler.postAtFrontOfQueue(ReceiverCastService.this::stopAudioPlaybackImmediate);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -304,6 +332,13 @@ public class ReceiverCastService extends Service {
|
||||
|
||||
private void onSenderConnected(String senderName, CastSettings remoteSettings, String videoMime) {
|
||||
phase = Phase.CONNECTED;
|
||||
castEnded = false;
|
||||
if (sessionStatsRecorder == null) {
|
||||
sessionStatsRecorder = new SessionStatsRecorder("receiver", settings.getTransport());
|
||||
sessionStatsRecorder.setClientCount(1);
|
||||
}
|
||||
sessionStatsRecorder.mergeSettings(remoteSettings);
|
||||
sessionStatsRecorder.setCodecs(videoMime, "AAC");
|
||||
negotiatedVideoMime = videoMime;
|
||||
remoteCastSettings = remoteSettings;
|
||||
streamIdle = false;
|
||||
@@ -332,25 +367,32 @@ public class ReceiverCastService extends Service {
|
||||
}, "AudioConsent").start();
|
||||
}
|
||||
|
||||
private void stopAudioPlaybackImmediate() {
|
||||
castEnded = true;
|
||||
if (audioDecoder != null) {
|
||||
audioDecoder.flushAndStop();
|
||||
audioDecoder = new AudioDecoder(this);
|
||||
audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered(
|
||||
pcmBytes, levels.peak, levels.rms, levels.silent));
|
||||
}
|
||||
Log.i(TAG, "Audio playback stopped");
|
||||
}
|
||||
|
||||
private void onSenderDisconnected() {
|
||||
stopAudioPlaybackImmediate();
|
||||
phase = Phase.LISTENING;
|
||||
audioAccepted = null;
|
||||
remoteCastSettings = null;
|
||||
streamIdle = false;
|
||||
lastVideoFrameMs = 0;
|
||||
negotiatedVideoMime = null;
|
||||
enterAwaitingMode(R.string.playback_stream_ended, true);
|
||||
updateStatus(getString(R.string.waiting_sender));
|
||||
closePlaybackUi();
|
||||
openReceiverSetupUi();
|
||||
Log.i(TAG, "Sender disconnected");
|
||||
}
|
||||
|
||||
private void openReceiverSetupUi() {
|
||||
Intent intent = new Intent(this, ReceiverActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
| Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
startActivity(intent);
|
||||
pendingWidth = 0;
|
||||
pendingHeight = 0;
|
||||
hasPendingConfig = false;
|
||||
resetDecoderState();
|
||||
enterAwaitingMode(R.string.playback_listening, true);
|
||||
updateStatus(getString(R.string.receiver_ready, activePin, settings.getTransport().toUpperCase()));
|
||||
Log.i(TAG, "Sender disconnected — listening again");
|
||||
}
|
||||
|
||||
private void checkStreamIdle() {
|
||||
@@ -360,6 +402,9 @@ public class ReceiverCastService extends Service {
|
||||
long idleMs = System.currentTimeMillis() - lastVideoFrameMs;
|
||||
if (idleMs >= CastConfig.STREAM_IDLE_TIMEOUT_MS) {
|
||||
Log.i(TAG, "Stream idle " + idleMs + "ms — awaiting");
|
||||
if (sessionStatsRecorder != null) {
|
||||
sessionStatsRecorder.noteIdleTimeout();
|
||||
}
|
||||
enterAwaitingMode(R.string.playback_awaiting_stream, false);
|
||||
}
|
||||
}
|
||||
@@ -459,6 +504,9 @@ public class ReceiverCastService extends Service {
|
||||
decoderActive = false;
|
||||
notifyPlaybackDimensions(pendingWidth, pendingHeight);
|
||||
scheduleDecoderConfigureRetries();
|
||||
if (sessionStatsRecorder != null) {
|
||||
sessionStatsRecorder.setResolution(pendingWidth, pendingHeight);
|
||||
}
|
||||
Log.i(TAG, "Stream config " + pendingWidth + "x" + pendingHeight);
|
||||
}
|
||||
|
||||
@@ -504,9 +552,11 @@ public class ReceiverCastService extends Service {
|
||||
return;
|
||||
}
|
||||
discovery = new DiscoveryManager(this);
|
||||
discovery.startAnnouncing(android.os.Build.MODEL, com.foxx.androidcast.CastConfig.CAST_PORT,
|
||||
com.foxx.androidcast.network.CastTransportFactory.init(this);
|
||||
int announcePort = com.foxx.androidcast.CastConfig.portForTransport(settings.getTransport());
|
||||
discovery.startAnnouncing(android.os.Build.MODEL, announcePort,
|
||||
settings.getTransport(), true);
|
||||
Log.i(TAG, "Discovery announcing on port " + com.foxx.androidcast.CastConfig.CAST_PORT);
|
||||
Log.i(TAG, "Discovery announcing on port " + announcePort);
|
||||
}
|
||||
|
||||
private void stopSessionOnly() {
|
||||
@@ -545,6 +595,7 @@ public class ReceiverCastService extends Service {
|
||||
if (!hasPendingConfig || attachedSurface == null || !attachedSurface.isValid()) {
|
||||
Log.i(TAG, "Decoder waiting: config=" + hasPendingConfig
|
||||
+ " surface=" + (attachedSurface != null && attachedSurface.isValid()));
|
||||
reconfiguring = false;
|
||||
return;
|
||||
}
|
||||
if (decoderActive && decoderWidth == pendingWidth && decoderHeight == pendingHeight) {
|
||||
@@ -569,16 +620,24 @@ public class ReceiverCastService extends Service {
|
||||
public void onFrameRendered() {
|
||||
ReceiverCastService.this.onFrameRendered();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOutputSizeChanged(int width, int height) {
|
||||
ReceiverCastService.this.onDecoderOutputSize(width, height);
|
||||
}
|
||||
});
|
||||
decoderActive = true;
|
||||
reconfiguring = false;
|
||||
decoderWidth = pendingWidth;
|
||||
decoderHeight = pendingHeight;
|
||||
updateStatus(getString(R.string.playing_resolution, pendingWidth, pendingHeight));
|
||||
updateStatus(getString(R.string.playback_awaiting_stream));
|
||||
broadcastStreamStarted(pendingWidth, pendingHeight);
|
||||
Log.i(TAG, "Decoder started " + pendingWidth + "x" + pendingHeight);
|
||||
} catch (Exception e) {
|
||||
decoderActive = false;
|
||||
synchronized (transportLoss) {
|
||||
transportLoss.recvVideoDecodeErrors++;
|
||||
}
|
||||
Log.e(TAG, "Video configure failed", e);
|
||||
updateStatus("Video: " + e.getMessage());
|
||||
}
|
||||
@@ -589,6 +648,9 @@ public class ReceiverCastService extends Service {
|
||||
streamMetrics.onRenderedFrame();
|
||||
Log.i(TAG, "First frame on screen");
|
||||
streamIdle = false;
|
||||
if (pendingWidth > 0 && pendingHeight > 0) {
|
||||
updateStatus(getString(R.string.playing_resolution, pendingWidth, pendingHeight));
|
||||
}
|
||||
broadcastVideoRendering();
|
||||
});
|
||||
}
|
||||
@@ -597,8 +659,27 @@ public class ReceiverCastService extends Service {
|
||||
mainHandler.post(() -> streamMetrics.onRenderedFrame());
|
||||
}
|
||||
|
||||
private void onDecoderOutputSize(int width, int height) {
|
||||
mainHandler.post(() -> {
|
||||
if (width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
pendingWidth = width;
|
||||
pendingHeight = height;
|
||||
decoderWidth = width;
|
||||
decoderHeight = height;
|
||||
notifyPlaybackDimensions(width, height);
|
||||
Log.i(TAG, "Decoder output size " + width + "x" + height);
|
||||
});
|
||||
}
|
||||
|
||||
private void enrichReceiverStats(NetworkStatsSnapshot s, long nowMs) {
|
||||
s.encodeFps = streamMetrics.getDecodeFps();
|
||||
TransportLossStats snap = buildReceiverLossSnapshot();
|
||||
TransportLossStatsBridge.applyTo(s, snap);
|
||||
if (sessionStatsRecorder != null) {
|
||||
sessionStatsRecorder.sampleNetwork(s, streamMetrics.getLastPeer());
|
||||
}
|
||||
NetworkStatsSnapshot peer = streamMetrics.getLastPeer();
|
||||
CastSettings tune = remoteCastSettings != null ? remoteCastSettings : settings;
|
||||
com.foxx.androidcast.CastTuningEngine.EffectiveParams eff =
|
||||
@@ -634,6 +715,34 @@ public class ReceiverCastService extends Service {
|
||||
PlaybackViewState.getZoomPercent());
|
||||
}
|
||||
|
||||
private TransportLossStats buildReceiverLossSnapshot() {
|
||||
TransportLossStats snap = session != null
|
||||
? session.getTransportLossStats() : new TransportLossStats();
|
||||
synchronized (transportLoss) {
|
||||
snap.recvVideoSkippedAwaitingKey = transportLoss.recvVideoSkippedAwaitingKey;
|
||||
snap.recvVideoPackets = transportLoss.recvVideoPackets;
|
||||
snap.recvAudioPackets = transportLoss.recvAudioPackets;
|
||||
snap.recvVideoDecodeErrors = transportLoss.recvVideoDecodeErrors;
|
||||
}
|
||||
if (audioDecoder != null) {
|
||||
AudioDecoder.Stats ast = audioDecoder.getStats();
|
||||
snap.recvAudioDecodeErrors = ast.decodeErrors;
|
||||
snap.recvAudioQueueDrops = ast.queueDrops;
|
||||
}
|
||||
return snap;
|
||||
}
|
||||
|
||||
private void finishSessionStatsAsync() {
|
||||
final SessionStatsRecorder recorder = sessionStatsRecorder;
|
||||
sessionStatsRecorder = null;
|
||||
if (recorder == null) {
|
||||
return;
|
||||
}
|
||||
recorder.setResolution(pendingWidth, pendingHeight);
|
||||
recorder.mergeLoss(buildReceiverLossSnapshot());
|
||||
new Thread(() -> recorder.finish(getApplicationContext()), "SessionStatsSave").start();
|
||||
}
|
||||
|
||||
private void attachSurface(Surface surface) {
|
||||
if (surface == null || !surface.isValid()) {
|
||||
Log.w(TAG, "attachSurface: invalid surface");
|
||||
@@ -732,6 +841,7 @@ public class ReceiverCastService extends Service {
|
||||
}
|
||||
|
||||
private void releaseAll() {
|
||||
finishSessionStatsAsync();
|
||||
phase = Phase.IDLE;
|
||||
resetStreamState();
|
||||
playbackLaunched = false;
|
||||
|
||||
@@ -9,8 +9,9 @@ import android.os.Handler;
|
||||
import android.os.IBinder;
|
||||
import android.os.Looper;
|
||||
import android.os.RemoteException;
|
||||
import android.view.SurfaceHolder;
|
||||
import android.view.SurfaceView;
|
||||
import android.graphics.SurfaceTexture;
|
||||
import android.view.Surface;
|
||||
import android.view.TextureView;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
import android.animation.AnimatorSet;
|
||||
@@ -27,6 +28,7 @@ import com.foxx.androidcast.ICastReceiverService;
|
||||
import com.foxx.androidcast.ICastStatusCallback;
|
||||
import com.foxx.androidcast.R;
|
||||
import com.foxx.androidcast.ui.AspectRatioFrameLayout;
|
||||
import com.foxx.androidcast.ui.PlaybackTextureTransform;
|
||||
import com.foxx.androidcast.ui.ZoomPanContainer;
|
||||
|
||||
import androidx.core.text.HtmlCompat;
|
||||
@@ -35,7 +37,7 @@ import androidx.core.text.HtmlCompat;
|
||||
* Fullscreen display: dancing-robot overlay while listening / waiting;
|
||||
* video surface shown only after the first decoded frame.
|
||||
*/
|
||||
public class ReceiverPlaybackActivity extends AppCompatActivity implements SurfaceHolder.Callback {
|
||||
public class ReceiverPlaybackActivity extends AppCompatActivity {
|
||||
public static final String EXTRA_WIDTH = "video_width";
|
||||
public static final String EXTRA_HEIGHT = "video_height";
|
||||
public static final String EXTRA_FINISH = "finish_playback";
|
||||
@@ -43,13 +45,13 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
||||
public static final String EXTRA_AWAITING_MESSAGE = "awaiting_message";
|
||||
|
||||
private AspectRatioFrameLayout aspectContainer;
|
||||
private SurfaceView surfaceView;
|
||||
private TextureView textureView;
|
||||
private View awaitingOverlay;
|
||||
private TextView diagnosticsText;
|
||||
private ImageView robotView;
|
||||
private TextView awaitingMessage;
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private SurfaceHolder surfaceHolder;
|
||||
private SurfaceTexture surfaceTexture;
|
||||
private int videoWidth;
|
||||
private int videoHeight;
|
||||
private boolean streamRendering;
|
||||
@@ -78,6 +80,11 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
||||
runOnUiThread(() -> {
|
||||
streamRendering = true;
|
||||
hideAwaitingOverlay();
|
||||
ZoomPanContainer zoom = findViewById(R.id.zoom_container);
|
||||
if (zoom != null) {
|
||||
zoom.resetTransform();
|
||||
}
|
||||
applyTextureFitCenter();
|
||||
updateDiagnosticsVisibility();
|
||||
});
|
||||
}
|
||||
@@ -138,15 +145,13 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
||||
|
||||
ZoomPanContainer zoomContainer = findViewById(R.id.zoom_container);
|
||||
aspectContainer = findViewById(R.id.aspect_container);
|
||||
surfaceView = findViewById(R.id.surface_playback);
|
||||
textureView = findViewById(R.id.surface_playback);
|
||||
zoomContainer.setOnZoomChangedListener(PlaybackViewState::setZoomPercent);
|
||||
awaitingOverlay = findViewById(R.id.overlay_awaiting);
|
||||
diagnosticsText = findViewById(R.id.text_diagnostics);
|
||||
robotView = findViewById(R.id.image_robot);
|
||||
awaitingMessage = findViewById(R.id.text_awaiting_message);
|
||||
surfaceHolder = surfaceView.getHolder();
|
||||
surfaceHolder.addCallback(this);
|
||||
surfaceView.setZOrderMediaOverlay(false);
|
||||
textureView.setSurfaceTextureListener(surfaceListener);
|
||||
applyAspectRatio();
|
||||
|
||||
if (getIntent().getBooleanExtra(EXTRA_SHOW_AWAITING, true)) {
|
||||
@@ -319,12 +324,24 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
||||
videoWidth = width;
|
||||
videoHeight = height;
|
||||
applyAspectRatio();
|
||||
if (surfaceHolder != null && surfaceHolder.getSurface() != null && surfaceHolder.getSurface().isValid()) {
|
||||
surfaceHolder.setFixedSize(videoWidth, videoHeight);
|
||||
applyTextureFitCenter();
|
||||
if (surfaceTexture != null) {
|
||||
surfaceTexture.setDefaultBufferSize(videoWidth, videoHeight);
|
||||
attachSurfaceIfReady();
|
||||
}
|
||||
}
|
||||
|
||||
private void applyTextureFitCenter() {
|
||||
if (textureView == null || videoWidth <= 0 || videoHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
if (textureView.getWidth() == 0 || textureView.getHeight() == 0) {
|
||||
textureView.post(this::applyTextureFitCenter);
|
||||
return;
|
||||
}
|
||||
PlaybackTextureTransform.applyFitCenter(textureView, videoWidth, videoHeight);
|
||||
}
|
||||
|
||||
private void applyFullscreen() {
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
getWindow().getDecorView().setSystemUiVisibility(
|
||||
@@ -340,42 +357,49 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
||||
}
|
||||
|
||||
private void applyAspectRatio() {
|
||||
if (videoWidth > 0 && videoHeight > 0) {
|
||||
aspectContainer.setAspectRatio(videoWidth, videoHeight);
|
||||
}
|
||||
// Full-screen TextureView; letterboxing via transform only (avoids double-scaling).
|
||||
aspectContainer.setAspectRatio(0, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void surfaceCreated(SurfaceHolder holder) {
|
||||
if (videoWidth > 0 && videoHeight > 0) {
|
||||
holder.setFixedSize(videoWidth, videoHeight);
|
||||
}
|
||||
surfaceSentToService = false;
|
||||
attachSurfaceIfReady();
|
||||
}
|
||||
private final TextureView.SurfaceTextureListener surfaceListener =
|
||||
new TextureView.SurfaceTextureListener() {
|
||||
@Override
|
||||
public void onSurfaceTextureAvailable(SurfaceTexture texture, int width, int height) {
|
||||
surfaceTexture = texture;
|
||||
if (videoWidth > 0 && videoHeight > 0) {
|
||||
texture.setDefaultBufferSize(videoWidth, videoHeight);
|
||||
}
|
||||
surfaceSentToService = false;
|
||||
attachSurfaceIfReady();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
|
||||
if (videoWidth > 0 && videoHeight > 0) {
|
||||
holder.setFixedSize(videoWidth, videoHeight);
|
||||
}
|
||||
}
|
||||
@Override
|
||||
public void onSurfaceTextureSizeChanged(SurfaceTexture texture, int width, int height) {
|
||||
if (videoWidth > 0 && videoHeight > 0) {
|
||||
texture.setDefaultBufferSize(videoWidth, videoHeight);
|
||||
}
|
||||
applyTextureFitCenter();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void surfaceDestroyed(SurfaceHolder holder) {
|
||||
surfaceSentToService = false;
|
||||
detachSurface();
|
||||
}
|
||||
@Override
|
||||
public boolean onSurfaceTextureDestroyed(SurfaceTexture texture) {
|
||||
surfaceSentToService = false;
|
||||
detachSurface();
|
||||
surfaceTexture = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSurfaceTextureUpdated(SurfaceTexture texture) {
|
||||
}
|
||||
};
|
||||
|
||||
private void attachSurfaceIfReady() {
|
||||
if (surfaceSentToService || !bound || receiverService == null || surfaceHolder.getSurface() == null) {
|
||||
return;
|
||||
}
|
||||
if (!surfaceHolder.getSurface().isValid()) {
|
||||
if (surfaceSentToService || !bound || receiverService == null || surfaceTexture == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
receiverService.attachVideoSurface(surfaceHolder.getSurface());
|
||||
receiverService.attachVideoSurface(new Surface(surfaceTexture));
|
||||
surfaceSentToService = true;
|
||||
} catch (RemoteException ignored) {
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ import com.foxx.androidcast.network.CastCodecFlags;
|
||||
import com.foxx.androidcast.network.CastFramingContext;
|
||||
import com.foxx.androidcast.network.CastProtocol;
|
||||
import com.foxx.androidcast.network.CastSession;
|
||||
import com.foxx.androidcast.network.CastSessionGate;
|
||||
import com.foxx.androidcast.network.CastTransport;
|
||||
import com.foxx.androidcast.network.CastTransportFactory;
|
||||
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||
import com.foxx.androidcast.network.control.NetworkFeedbackManager;
|
||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||
@@ -41,6 +43,9 @@ public class ReceiverSession {
|
||||
|
||||
void onDisconnected();
|
||||
|
||||
/** Sender sent {@link CastProtocol#MSG_GOODBYE} — stop audio/video playback immediately. */
|
||||
void onCastEnded();
|
||||
|
||||
/** TCP/UDP listen socket is bound; safe to advertise on the LAN. */
|
||||
void onListening();
|
||||
|
||||
@@ -58,6 +63,7 @@ public class ReceiverSession {
|
||||
|
||||
private CastSession session;
|
||||
private CastTransport listenTransport;
|
||||
private final CastSessionGate inboundSessionGate = new CastSessionGate();
|
||||
private NetworkFeedbackManager networkFeedback;
|
||||
private PassThroughNetworkControlPlane.StatsEnricher statsEnricher;
|
||||
|
||||
@@ -94,6 +100,14 @@ public class ReceiverSession {
|
||||
listenTransport.close();
|
||||
listenTransport = null;
|
||||
}
|
||||
inboundSessionGate.reset();
|
||||
}
|
||||
|
||||
public TransportLossStats getTransportLossStats() {
|
||||
if (listenTransport != null) {
|
||||
return listenTransport.getLossStats();
|
||||
}
|
||||
return new TransportLossStats();
|
||||
}
|
||||
|
||||
public void sendAudioConsent(boolean accept) {
|
||||
@@ -111,17 +125,21 @@ public class ReceiverSession {
|
||||
while (running.get()) {
|
||||
try {
|
||||
if (listenTransport == null) {
|
||||
com.foxx.androidcast.network.CastTransportFactory.init(appContext);
|
||||
listenTransport = CastTransportFactory.create(localSettings);
|
||||
inboundSessionGate.reset();
|
||||
listenTransport.setInboundSessionGate(inboundSessionGate);
|
||||
listenTransport.setFramingContext(new CastFramingContext(
|
||||
0,
|
||||
DeviceInfo.getPlaceholderId(appContext),
|
||||
CastCodecFlags.localDecoderVideoMask(),
|
||||
CastCodecFlags.localAudioMask()));
|
||||
session = new CastSession(listenTransport);
|
||||
session.prepareListen(CastConfig.CAST_PORT);
|
||||
int listenPort = CastConfig.portForTransport(localSettings.getTransport());
|
||||
session.prepareListen(listenPort);
|
||||
listener.onListening();
|
||||
postStatus("Listening (" + listenTransport.getModeLabel() + ") port "
|
||||
+ CastConfig.CAST_PORT);
|
||||
+ listenPort);
|
||||
} else if (session == null) {
|
||||
session = new CastSession(listenTransport);
|
||||
}
|
||||
@@ -158,7 +176,13 @@ public class ReceiverSession {
|
||||
} finally {
|
||||
if (session != null) {
|
||||
session.releaseConnection();
|
||||
session = null;
|
||||
}
|
||||
if (listenTransport != null) {
|
||||
listenTransport.close();
|
||||
listenTransport = null;
|
||||
}
|
||||
inboundSessionGate.reset();
|
||||
networkFeedback = null;
|
||||
listener.onDisconnected();
|
||||
}
|
||||
@@ -214,6 +238,7 @@ public class ReceiverSession {
|
||||
break;
|
||||
case CastProtocol.MSG_GOODBYE:
|
||||
postStatus("Sender stopped casting");
|
||||
listener.onCastEnded();
|
||||
return;
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -22,6 +22,9 @@ public class VideoDecoder {
|
||||
|
||||
/** Called for each frame after the first. */
|
||||
default void onFrameRendered() {}
|
||||
|
||||
/** Decoder output size (may differ from stream config crop). */
|
||||
default void onOutputSizeChanged(int width, int height) {}
|
||||
}
|
||||
|
||||
private final Object lock = new Object();
|
||||
@@ -66,7 +69,7 @@ public class VideoDecoder {
|
||||
if (released || data == null || data.length == 0) {
|
||||
return;
|
||||
}
|
||||
byte[] annexB = CodecNegotiator.isH264(mime) ? H264Bitstream.toAnnexB(data) : data;
|
||||
byte[] annexB = needsAnnexB(mime) ? H264Bitstream.toAnnexB(data) : data;
|
||||
if (!configured || codec == null) {
|
||||
pending.add(new PendingFrame(ptsUs, keyFrame, annexB));
|
||||
return;
|
||||
@@ -143,7 +146,9 @@ public class VideoDecoder {
|
||||
break;
|
||||
}
|
||||
if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
||||
Log.i(TAG, "Output format: " + c.getOutputFormat());
|
||||
MediaFormat fmt = c.getOutputFormat();
|
||||
Log.i(TAG, "Output format: " + fmt);
|
||||
notifyOutputSizeLocked(fmt);
|
||||
continue;
|
||||
}
|
||||
if (outIndex < 0) {
|
||||
@@ -161,6 +166,23 @@ public class VideoDecoder {
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyOutputSizeLocked(MediaFormat format) {
|
||||
if (listener == null || format == null) {
|
||||
return;
|
||||
}
|
||||
int w = format.getInteger(MediaFormat.KEY_WIDTH);
|
||||
int h = format.getInteger(MediaFormat.KEY_HEIGHT);
|
||||
if (format.containsKey("crop-right") && format.containsKey("crop-left")) {
|
||||
w = format.getInteger("crop-right") + 1 - format.getInteger("crop-left");
|
||||
}
|
||||
if (format.containsKey("crop-bottom") && format.containsKey("crop-top")) {
|
||||
h = format.getInteger("crop-bottom") + 1 - format.getInteger("crop-top");
|
||||
}
|
||||
if (w > 0 && h > 0) {
|
||||
listener.onOutputSizeChanged(w, h);
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyFirstFrameLocked() {
|
||||
if (listener == null) {
|
||||
return;
|
||||
@@ -175,6 +197,10 @@ public class VideoDecoder {
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean needsAnnexB(String mime) {
|
||||
return CodecNegotiator.isH264(mime) || CodecNegotiator.isHevc(mime);
|
||||
}
|
||||
|
||||
private static final class PendingFrame {
|
||||
final long ptsUs;
|
||||
final boolean keyFrame;
|
||||
|
||||
@@ -50,6 +50,10 @@ public class AudioCapture {
|
||||
Log.w(TAG, "Audio capture requires API 29+");
|
||||
return;
|
||||
}
|
||||
if (projection == null) {
|
||||
startMicOnly(callback);
|
||||
return;
|
||||
}
|
||||
if (!startPlayback(projection)) {
|
||||
Log.e(TAG, "Playback capture unavailable");
|
||||
return;
|
||||
@@ -72,6 +76,59 @@ public class AudioCapture {
|
||||
thread.start();
|
||||
}
|
||||
|
||||
private void startMicOnly(Callback callback) {
|
||||
micRecord = canUseMic() ? buildMicRecord() : null;
|
||||
if (micRecord == null || micRecord.getState() != AudioRecord.STATE_INITIALIZED) {
|
||||
micRecord = null;
|
||||
Log.w(TAG, "Mic-only capture unavailable");
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
micRecord.startRecording();
|
||||
captureMode = "mic";
|
||||
if (callback != null) {
|
||||
callback.onCaptureModeChanged(captureMode);
|
||||
}
|
||||
Log.i(TAG, "Mic-only capture for calibration VU");
|
||||
thread = new Thread(() -> micOnlyLoop(callback), "AudioCaptureMic");
|
||||
thread.start();
|
||||
}
|
||||
|
||||
private void micOnlyLoop(Callback callback) {
|
||||
int frameBytes = pcmFrameBytes();
|
||||
byte[] micBuf = new byte[frameBytes];
|
||||
byte[] copy = new byte[frameBytes];
|
||||
long frameIndex = 0;
|
||||
while (running) {
|
||||
if (!readFully(micRecord, micBuf, frameBytes)) {
|
||||
continue;
|
||||
}
|
||||
System.arraycopy(micBuf, 0, copy, 0, frameBytes);
|
||||
long ptsUs = frameIndex * 20_000L;
|
||||
frameIndex++;
|
||||
if (callback != null) {
|
||||
callback.onPcm(copy, frameBytes, ptsUs,
|
||||
com.foxx.androidcast.media.PcmLevelAnalyzer.analyze(copy, frameBytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int pcmFrameBytes() {
|
||||
return CastConfig.AUDIO_SAMPLE_RATE * CastConfig.AUDIO_CHANNEL_COUNT * 2 / 50;
|
||||
}
|
||||
|
||||
private static boolean readFully(AudioRecord record, byte[] buf, int length) {
|
||||
int off = 0;
|
||||
while (off < length && record != null) {
|
||||
int n = record.read(buf, off, length - off);
|
||||
if (n <= 0) {
|
||||
return false;
|
||||
}
|
||||
off += n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean startPlayback(MediaProjection projection) {
|
||||
try {
|
||||
playbackRecord = buildPlaybackRecord(projection);
|
||||
@@ -138,40 +195,32 @@ public class AudioCapture {
|
||||
}
|
||||
|
||||
private void mixLoop(Callback callback) {
|
||||
byte[] playBuf = new byte[4096];
|
||||
byte[] micBuf = new byte[4096];
|
||||
long startNs = System.nanoTime();
|
||||
int frameBytes = pcmFrameBytes();
|
||||
byte[] playBuf = new byte[frameBytes];
|
||||
byte[] micBuf = new byte[frameBytes];
|
||||
byte[] mixOut = new byte[frameBytes];
|
||||
long frameIndex = 0;
|
||||
while (running) {
|
||||
int pRead = playbackRecord.read(playBuf, 0, playBuf.length);
|
||||
int mRead = micRecord != null ? micRecord.read(micBuf, 0, micBuf.length) : 0;
|
||||
if (pRead < 0 && mRead < 0) {
|
||||
Log.w(TAG, "Audio read error");
|
||||
if (!readFully(playbackRecord, playBuf, frameBytes)) {
|
||||
continue;
|
||||
}
|
||||
byte[] out;
|
||||
int outLen;
|
||||
if (pRead > 0) {
|
||||
out = playBuf;
|
||||
outLen = pRead;
|
||||
String mode = hasAudibleSamples(playBuf, pRead) ? "playback" : "playback-quiet";
|
||||
if (!mode.equals(captureMode)) {
|
||||
captureMode = mode;
|
||||
notifyMode(callback, captureMode);
|
||||
int mRead = micRecord != null ? micRecord.read(micBuf, 0, frameBytes) : 0;
|
||||
System.arraycopy(playBuf, 0, mixOut, 0, frameBytes);
|
||||
if (mRead > 0 && !hasAudibleSamples(playBuf, frameBytes)) {
|
||||
for (int i = 0; i < frameBytes; i++) {
|
||||
mixOut[i] = (byte) Math.max(mixOut[i] & 0xff, micBuf[i] & 0xff);
|
||||
}
|
||||
} else if (mRead > 0 && micRecord != null) {
|
||||
out = micBuf;
|
||||
outLen = mRead;
|
||||
if (!"mic".equals(captureMode)) {
|
||||
captureMode = "mic";
|
||||
notifyMode(callback, captureMode);
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
long ptsUs = (System.nanoTime() - startNs) / 1000;
|
||||
String mode = hasAudibleSamples(playBuf, frameBytes) ? "playback" : "playback-quiet";
|
||||
if (!mode.equals(captureMode)) {
|
||||
captureMode = mode;
|
||||
notifyMode(callback, captureMode);
|
||||
}
|
||||
long ptsUs = frameIndex * 20_000L;
|
||||
frameIndex++;
|
||||
if (callback != null) {
|
||||
PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(out, outLen);
|
||||
callback.onPcm(out, outLen, ptsUs, levels);
|
||||
PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(mixOut, frameBytes);
|
||||
callback.onPcm(mixOut, frameBytes, ptsUs, levels);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,8 @@ public class AudioEncoder {
|
||||
|
||||
public void stop() {
|
||||
running = false;
|
||||
MediaCodec c = codec;
|
||||
codec = null;
|
||||
if (drainThread != null) {
|
||||
try {
|
||||
drainThread.join(1000);
|
||||
@@ -92,13 +94,15 @@ public class AudioEncoder {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
if (codec != null) {
|
||||
if (c != null) {
|
||||
try {
|
||||
codec.stop();
|
||||
c.stop();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
c.release();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
codec.release();
|
||||
codec = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,25 +11,32 @@ import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Background TCP/UDP send queue so encoding is not blocked by network I/O. */
|
||||
/** Background send queue: audio has its own lane so video backlog cannot starve it. */
|
||||
public final class CastSendPump {
|
||||
private static final String TAG = "CastSendPump";
|
||||
private static final int MAX_QUEUE = 48;
|
||||
private static final int MAX_VIDEO_QUEUE = 40;
|
||||
private static final int MAX_AUDIO_QUEUE = 96;
|
||||
|
||||
private final LinkedBlockingQueue<Outgoing> queue = new LinkedBlockingQueue<>(MAX_QUEUE);
|
||||
private final LinkedBlockingQueue<Outgoing> videoQueue = new LinkedBlockingQueue<>(MAX_VIDEO_QUEUE);
|
||||
private final LinkedBlockingQueue<Outgoing> audioQueue = new LinkedBlockingQueue<>(MAX_AUDIO_QUEUE);
|
||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||
private Thread thread;
|
||||
private CastSession session;
|
||||
private Runnable onSendFailed;
|
||||
private PassThroughNetworkControlPlane statsPlane;
|
||||
private volatile int droppedPFrames;
|
||||
private volatile int droppedAudioFrames;
|
||||
|
||||
public int getDroppedPFrames() {
|
||||
return droppedPFrames;
|
||||
}
|
||||
|
||||
public int getDroppedAudioFrames() {
|
||||
return droppedAudioFrames;
|
||||
}
|
||||
|
||||
public int getQueueDepth() {
|
||||
return queue.size();
|
||||
return videoQueue.size() + audioQueue.size();
|
||||
}
|
||||
|
||||
public void start(CastSession session, Runnable onSendFailed) {
|
||||
@@ -44,21 +51,23 @@ public final class CastSendPump {
|
||||
return;
|
||||
}
|
||||
thread = new Thread(this::run, "CastSendPump");
|
||||
thread.setPriority(Thread.NORM_PRIORITY + 1);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
running.set(false);
|
||||
videoQueue.clear();
|
||||
audioQueue.clear();
|
||||
if (thread != null) {
|
||||
thread.interrupt();
|
||||
try {
|
||||
thread.join(1500);
|
||||
thread.join(500);
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
thread = null;
|
||||
}
|
||||
queue.clear();
|
||||
session = null;
|
||||
}
|
||||
|
||||
@@ -67,17 +76,12 @@ public final class CastSendPump {
|
||||
return;
|
||||
}
|
||||
Outgoing item = Outgoing.video(ptsUs, keyFrame, annexBReadyPayload);
|
||||
if (!queue.offer(item)) {
|
||||
if (!videoQueue.offer(item)) {
|
||||
if (!keyFrame) {
|
||||
Outgoing dropped = queue.poll();
|
||||
if (dropped != null && !dropped.keyFrame) {
|
||||
droppedPFrames++;
|
||||
}
|
||||
dropOneVideoPFrame();
|
||||
}
|
||||
if (!queue.offer(item)) {
|
||||
if (!keyFrame) {
|
||||
droppedPFrames++;
|
||||
}
|
||||
if (!videoQueue.offer(item) && !keyFrame) {
|
||||
droppedPFrames++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,15 +91,43 @@ public final class CastSendPump {
|
||||
return;
|
||||
}
|
||||
Outgoing item = Outgoing.raw(type, payload);
|
||||
while (running.get() && !queue.offer(item)) {
|
||||
queue.poll();
|
||||
boolean audio = type == CastProtocol.MSG_AUDIO_FRAME || type == CastProtocol.MSG_AUDIO_CONFIG;
|
||||
if (audio) {
|
||||
if (!audioQueue.offer(item)) {
|
||||
Outgoing dropped = audioQueue.poll();
|
||||
if (dropped != null) {
|
||||
droppedAudioFrames++;
|
||||
}
|
||||
audioQueue.offer(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
while (running.get() && !videoQueue.offer(item)) {
|
||||
dropOneVideoPFrame();
|
||||
}
|
||||
}
|
||||
|
||||
private void dropOneVideoPFrame() {
|
||||
for (Outgoing o : videoQueue) {
|
||||
if (o.type == CastProtocol.MSG_VIDEO_FRAME && !o.keyFrame) {
|
||||
videoQueue.remove(o);
|
||||
droppedPFrames++;
|
||||
return;
|
||||
}
|
||||
}
|
||||
Outgoing head = videoQueue.poll();
|
||||
if (head != null && head.type == CastProtocol.MSG_VIDEO_FRAME && !head.keyFrame) {
|
||||
droppedPFrames++;
|
||||
}
|
||||
}
|
||||
|
||||
private void run() {
|
||||
while (running.get()) {
|
||||
try {
|
||||
Outgoing item = queue.poll(200, TimeUnit.MILLISECONDS);
|
||||
Outgoing item = audioQueue.poll();
|
||||
if (item == null) {
|
||||
item = videoQueue.poll(50, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
if (item == null || session == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -149,18 +149,27 @@ public final class MultiCastCoordinator {
|
||||
return any;
|
||||
}
|
||||
|
||||
public void closeAll() {
|
||||
public void sendGoodbyeAll() {
|
||||
for (ActivePeer p : peers) {
|
||||
try {
|
||||
p.session.send(CastProtocol.MSG_GOODBYE, new byte[0]);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
p.session.close();
|
||||
}
|
||||
}
|
||||
|
||||
public void closeAll() {
|
||||
for (ActivePeer p : peers) {
|
||||
try {
|
||||
p.session.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
peers.clear();
|
||||
}
|
||||
|
||||
private CastSession createSession(CastSettings settings) throws IOException {
|
||||
CastTransportFactory.init(appContext);
|
||||
CastSession session = new CastSession(CastTransportFactory.create(settings));
|
||||
session.getTransport().setFramingContext(framingContext);
|
||||
return session;
|
||||
|
||||
@@ -36,6 +36,10 @@ import com.foxx.androidcast.network.CastTransportFactory;
|
||||
import com.foxx.androidcast.network.control.NetworkFeedbackManager;
|
||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||
import com.foxx.androidcast.network.transport.TransportLossStatsBridge;
|
||||
import com.foxx.androidcast.stats.SessionStatsRecorder;
|
||||
import com.foxx.androidcast.stats.SessionStatsStore;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
@@ -46,7 +50,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/** Foreground sender: screen + audio capture → encode → TCP/UDP (survives UI exit). */
|
||||
public class ScreenCastService extends Service {
|
||||
public class ScreenCastService extends Service implements
|
||||
com.foxx.androidcast.sender.calibration.CalibrationCastDriver.FallbackHost {
|
||||
private static final String TAG = "ScreenCastService";
|
||||
private static final int CONNECT_RETRIES = 20;
|
||||
private static final long CONNECT_RETRY_MS = 1000;
|
||||
@@ -58,12 +63,14 @@ public class ScreenCastService extends Service {
|
||||
public static final String EXTRA_PIN = "pin";
|
||||
public static final String EXTRA_DEVICE_NAME = "device_name";
|
||||
public static final String EXTRA_TARGETS = "cast_targets";
|
||||
public static final String EXTRA_CALIBRATION = "calibration_mode";
|
||||
public static final String ACTION_STOP = "com.foxx.androidcast.STOP_SEND";
|
||||
|
||||
private final RemoteCallbackList<ICastStatusCallback> callbacks = new RemoteCallbackList<>();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private final AtomicBoolean stopping = new AtomicBoolean(false);
|
||||
private final AtomicBoolean casting = new AtomicBoolean(false);
|
||||
private final AtomicBoolean resourcesReleased = new AtomicBoolean(false);
|
||||
|
||||
private MediaProjection projection;
|
||||
private VirtualDisplay virtualDisplay;
|
||||
@@ -92,6 +99,11 @@ public class ScreenCastService extends Service {
|
||||
private int captureDensity;
|
||||
private DisplayManager displayManager;
|
||||
private DisplayManager.DisplayListener displayListener;
|
||||
private com.foxx.androidcast.sender.calibration.CalibrationCastDriver calibrationDriver;
|
||||
private volatile boolean calibrationMode;
|
||||
private volatile boolean cameraMode;
|
||||
private com.foxx.androidcast.sender.camera.CameraCastCapture cameraCapture;
|
||||
private SessionStatsRecorder sessionStatsRecorder;
|
||||
|
||||
private final ICastSenderService.Stub binder = new ICastSenderService.Stub() {
|
||||
@Override
|
||||
@@ -128,7 +140,9 @@ public class ScreenCastService extends Service {
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
SessionStatsStore.pruneOnAppStart(this);
|
||||
CastNotifications.createChannels(this);
|
||||
com.foxx.androidcast.network.CastTransportFactory.init(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -149,6 +163,8 @@ public class ScreenCastService extends Service {
|
||||
}
|
||||
|
||||
private void runCast(Intent intent) {
|
||||
resourcesReleased.set(false);
|
||||
stopping.set(false);
|
||||
if (intent == null) {
|
||||
failAndStop(getString(R.string.error_no_intent));
|
||||
return;
|
||||
@@ -161,7 +177,11 @@ public class ScreenCastService extends Service {
|
||||
String deviceName = intent.getStringExtra(EXTRA_DEVICE_NAME);
|
||||
CastSettings settings = IntentExtras.getCastSettings(intent);
|
||||
|
||||
if (data == null) {
|
||||
boolean calibration = settings != null
|
||||
&& settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST;
|
||||
boolean camera = settings != null
|
||||
&& settings.getCaptureMode() == CastSettings.CaptureMode.CAMERA;
|
||||
if (!calibration && !camera && data == null) {
|
||||
failAndStop(getString(R.string.error_projection_data));
|
||||
return;
|
||||
}
|
||||
@@ -169,6 +189,16 @@ public class ScreenCastService extends Service {
|
||||
failAndStop(getString(R.string.error_missing_params));
|
||||
return;
|
||||
}
|
||||
cameraMode = settings.getCaptureMode() == CastSettings.CaptureMode.CAMERA;
|
||||
calibrationMode = settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST;
|
||||
if (cameraMode && !com.foxx.androidcast.PlatformCastSupport.isCameraCaptureModeSupported()) {
|
||||
failAndStop(getString(R.string.capture_camera_not_implemented));
|
||||
return;
|
||||
}
|
||||
int castPort = com.foxx.androidcast.CastConfig.portForTransport(settings.getTransport());
|
||||
if (port <= 0 || port == com.foxx.androidcast.CastConfig.CAST_PORT) {
|
||||
port = castPort;
|
||||
}
|
||||
|
||||
acquireWakeLock();
|
||||
try {
|
||||
@@ -210,7 +240,17 @@ public class ScreenCastService extends Service {
|
||||
casting.set(true);
|
||||
CastActiveState.setSenderCasting(true);
|
||||
activeSettings = settings;
|
||||
startCaptureOnMainThread(resultCode, new Intent(data), settings);
|
||||
sessionStatsRecorder = new SessionStatsRecorder("sender", settings.getTransport());
|
||||
sessionStatsRecorder.mergeSettings(settings);
|
||||
sessionStatsRecorder.setClientCount(peers.size());
|
||||
sessionStatsRecorder.setCodecs(settings.getNegotiatedVideoMime(), "AAC");
|
||||
if (calibration) {
|
||||
startCalibrationCaptureOnMainThread(settings);
|
||||
} else if (camera) {
|
||||
startCameraCaptureOnMainThread(settings);
|
||||
} else {
|
||||
startCaptureOnMainThread(resultCode, new Intent(data), settings);
|
||||
}
|
||||
scheduleTuningTick();
|
||||
while (!stopping.get()) {
|
||||
try {
|
||||
@@ -285,6 +325,83 @@ public class ScreenCastService extends Service {
|
||||
throw new IOException(getString(R.string.error_connect_failed, host, port), last);
|
||||
}
|
||||
|
||||
private void startCalibrationCaptureOnMainThread(CastSettings settings) throws IOException {
|
||||
if (Looper.myLooper() != mainHandler.getLooper()) {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<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();
|
||||
}
|
||||
});
|
||||
try {
|
||||
if (!latch.await(15, TimeUnit.SECONDS)) {
|
||||
throw new IOException("Calibration setup timeout");
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted", e);
|
||||
}
|
||||
if (error.get() != null) {
|
||||
throw error.get();
|
||||
}
|
||||
return;
|
||||
}
|
||||
CastResolution.Size size = CastResolution.calibrationTestSize();
|
||||
setupVideoPipeline(size, settings, true);
|
||||
startAudioPipeline(settings, true);
|
||||
updateStatus(getString(R.string.casting_calibration, captureWidth, captureHeight));
|
||||
}
|
||||
|
||||
private void startCameraCaptureOnMainThread(CastSettings settings) throws IOException {
|
||||
if (Looper.myLooper() != mainHandler.getLooper()) {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<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();
|
||||
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
|
||||
wm.getDefaultDisplay().getRealMetrics(metrics);
|
||||
CastResolution.Size size = CastResolution.compute(metrics, settings, getPeerStats());
|
||||
setupVideoPipeline(size, settings, false);
|
||||
Surface surface = videoEncoder != null ? videoEncoder.getInputSurface() : null;
|
||||
if (surface == null) {
|
||||
throw new IOException("Encoder surface unavailable for camera");
|
||||
}
|
||||
cameraCapture = new com.foxx.androidcast.sender.camera.CameraCastCapture(this);
|
||||
try {
|
||||
cameraCapture.start(surface, captureWidth, captureHeight);
|
||||
} catch (android.hardware.camera2.CameraAccessException e) {
|
||||
throw new IOException("Camera open failed: " + e.getMessage(), e);
|
||||
}
|
||||
startAudioPipeline(settings, true);
|
||||
updateStatus(getString(R.string.casting_camera, captureWidth, captureHeight));
|
||||
}
|
||||
|
||||
private void startCaptureOnMainThread(int resultCode, Intent data, CastSettings settings) throws IOException {
|
||||
if (Looper.myLooper() != mainHandler.getLooper()) {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
@@ -335,16 +452,17 @@ public class ScreenCastService extends Service {
|
||||
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
|
||||
wm.getDefaultDisplay().getRealMetrics(metrics);
|
||||
CastResolution.Size size = CastResolution.compute(metrics, settings, getPeerStats());
|
||||
setupVideoPipeline(size, settings);
|
||||
setupVideoPipeline(size, settings, false);
|
||||
registerDisplayListener();
|
||||
|
||||
startAudioPipeline(settings);
|
||||
startAudioPipeline(settings, false);
|
||||
|
||||
String mode = session.getTransport().getModeLabel();
|
||||
updateStatus(getString(R.string.casting_active, mode, captureWidth, captureHeight));
|
||||
}
|
||||
|
||||
private void setupVideoPipeline(CastResolution.Size size, CastSettings settings) throws IOException {
|
||||
private void setupVideoPipeline(CastResolution.Size size, CastSettings settings, boolean calibration)
|
||||
throws IOException {
|
||||
if (settings.isPassthrough()) {
|
||||
throw new IOException("Passthrough video is not implemented yet (debug only)");
|
||||
}
|
||||
@@ -376,45 +494,29 @@ public class ScreenCastService extends Service {
|
||||
}
|
||||
|
||||
videoEncoder = new VideoEncoder();
|
||||
VideoEncoder.Callback videoCb = new VideoEncoder.Callback() {
|
||||
@Override
|
||||
public void onConfig(int w, int h, byte[] csd0, byte[] csd1) {
|
||||
if (stopping.get()) {
|
||||
return;
|
||||
}
|
||||
sendSafe(CastProtocol.MSG_STREAM_CONFIG,
|
||||
() -> CastProtocol.streamConfigPayload(w, h, csd0, csd1));
|
||||
}
|
||||
VideoEncoder.Callback videoCb = buildVideoEncoderCallback(mime);
|
||||
|
||||
@Override
|
||||
public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
|
||||
if ((sendPump == null && fanoutPump == null) || frameData == null) {
|
||||
return;
|
||||
}
|
||||
boolean tiny = effectiveParams != null && effectiveParams.skipTinyPFrames
|
||||
&& !keyFrame
|
||||
&& frameData.length < CastConfig.MIN_SIGNIFICANT_FRAME_BYTES;
|
||||
if (tiny) {
|
||||
return;
|
||||
}
|
||||
lastSignificantFrameMs = System.currentTimeMillis();
|
||||
senderMetrics.onEncodedFrame(keyFrame);
|
||||
enqueueVideoFrame(ptsUs, keyFrame, frameData);
|
||||
}
|
||||
};
|
||||
if (calibration) {
|
||||
startCalibrationVideoEncoder(mime, effectiveParams, videoCb, true);
|
||||
return;
|
||||
}
|
||||
|
||||
Surface surface = videoEncoder.prepare(captureWidth, captureHeight, mime, effectiveParams, videoCb);
|
||||
encoderKeepalive = new EncoderKeepalive();
|
||||
encoderKeepalive.start(videoEncoder);
|
||||
virtualDisplay = projection.createVirtualDisplay(
|
||||
"AndroidCast",
|
||||
captureWidth,
|
||||
captureHeight,
|
||||
captureDensity,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
surface,
|
||||
null,
|
||||
null);
|
||||
if (projection != null) {
|
||||
virtualDisplay = projection.createVirtualDisplay(
|
||||
"AndroidCast",
|
||||
captureWidth,
|
||||
captureHeight,
|
||||
captureDensity,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
surface,
|
||||
null,
|
||||
null);
|
||||
} else {
|
||||
throw new IOException("MediaProjection required for screen capture");
|
||||
}
|
||||
videoEncoder.requestKeyframe();
|
||||
Log.i(TAG, "Video pipeline " + captureWidth + "x" + captureHeight);
|
||||
}
|
||||
@@ -450,7 +552,8 @@ public class ScreenCastService extends Service {
|
||||
}
|
||||
|
||||
private void maybeReconfigureForRotation() {
|
||||
if (!casting.get() || projection == null || activeSettings == null || stopping.get()) {
|
||||
if (calibrationMode || cameraMode || !casting.get() || projection == null
|
||||
|| activeSettings == null || stopping.get()) {
|
||||
return;
|
||||
}
|
||||
DisplayMetrics metrics = new DisplayMetrics();
|
||||
@@ -461,7 +564,7 @@ public class ScreenCastService extends Service {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setupVideoPipeline(size, activeSettings);
|
||||
setupVideoPipeline(size, activeSettings, false);
|
||||
updateStatus(getString(R.string.casting_rotated, captureWidth, captureHeight));
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Rotation reconfigure failed", e);
|
||||
@@ -484,6 +587,11 @@ public class ScreenCastService extends Service {
|
||||
s.encodeQueueDepth = sendPump.getQueueDepth();
|
||||
s.droppedPFrames = sendPump.getDroppedPFrames();
|
||||
}
|
||||
TransportLossStats snap = buildSenderLossSnapshot();
|
||||
TransportLossStatsBridge.applyTo(s, snap);
|
||||
if (sessionStatsRecorder != null) {
|
||||
sessionStatsRecorder.sampleNetwork(s, getPeerStats());
|
||||
}
|
||||
s.audioActive = audioEncoder != null && audioFramesSent > 0;
|
||||
s.audioFramesSent = audioFramesSent;
|
||||
s.audioCaptureVuPercent = senderAudioMetrics.getVuPercent();
|
||||
@@ -543,7 +651,7 @@ public class ScreenCastService extends Service {
|
||||
}
|
||||
|
||||
private void maybeAdaptCaptureSize(NetworkStatsSnapshot peer) {
|
||||
if (activeSettings == null
|
||||
if (calibrationMode || cameraMode || activeSettings == null
|
||||
|| activeSettings.getResolution() != CastSettings.Resolution.ADAPTIVE) {
|
||||
return;
|
||||
}
|
||||
@@ -557,19 +665,19 @@ public class ScreenCastService extends Service {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setupVideoPipeline(size, activeSettings);
|
||||
setupVideoPipeline(size, activeSettings, false);
|
||||
Log.i(TAG, "Adaptive capture " + captureWidth + "x" + captureHeight);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Adaptive resize failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void startAudioPipeline(CastSettings settings) {
|
||||
private void startAudioPipeline(CastSettings settings, boolean calibrationOnlyMic) {
|
||||
if (!settings.isAudioEnabled() || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
Log.i(TAG, "Audio capture disabled for this session");
|
||||
return;
|
||||
}
|
||||
if (projection == null) {
|
||||
if (projection == null && !calibrationOnlyMic) {
|
||||
Log.w(TAG, "Audio skipped — no MediaProjection");
|
||||
return;
|
||||
}
|
||||
@@ -609,11 +717,15 @@ public class ScreenCastService extends Service {
|
||||
senderAudioMetrics.reset();
|
||||
audioCaptureMode = "playback";
|
||||
audioCapture = new AudioCapture(this);
|
||||
audioCapture.start(projection, new AudioCapture.Callback() {
|
||||
MediaProjection audioProjection = calibrationOnlyMic ? null : projection;
|
||||
audioCapture.start(audioProjection, new AudioCapture.Callback() {
|
||||
@Override
|
||||
public void onPcm(byte[] pcm, int length, long ptsUs,
|
||||
com.foxx.androidcast.media.PcmLevelAnalyzer.Levels levels) {
|
||||
senderAudioMetrics.onPcm(levels);
|
||||
if (calibrationDriver != null) {
|
||||
calibrationDriver.setVuFromPcm(pcm, length);
|
||||
}
|
||||
if (audioEncoder != null) {
|
||||
audioEncoder.queuePcm(pcm, length, ptsUs);
|
||||
}
|
||||
@@ -634,7 +746,8 @@ public class ScreenCastService extends Service {
|
||||
private void handleAudioConsent(boolean accept) {
|
||||
if (accept) {
|
||||
if (audioEncoder == null && activeSettings != null) {
|
||||
startAudioPipeline(activeSettings);
|
||||
startAudioPipeline(activeSettings, activeSettings != null
|
||||
&& activeSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST);
|
||||
}
|
||||
Log.i(TAG, "Receiver accepted audio");
|
||||
} else {
|
||||
@@ -695,6 +808,10 @@ public class ScreenCastService extends Service {
|
||||
}
|
||||
|
||||
private void releaseAll() {
|
||||
if (!resourcesReleased.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
finishSessionStatsAsync();
|
||||
stopping.set(true);
|
||||
casting.set(false);
|
||||
CastActiveState.setSenderCasting(false);
|
||||
@@ -708,10 +825,16 @@ public class ScreenCastService extends Service {
|
||||
encoderKeepalive.stop();
|
||||
encoderKeepalive = null;
|
||||
}
|
||||
if (videoEncoder != null) {
|
||||
videoEncoder.stop();
|
||||
videoEncoder = null;
|
||||
if (cameraCapture != null) {
|
||||
cameraCapture.stop();
|
||||
cameraCapture = null;
|
||||
}
|
||||
if (calibrationDriver != null) {
|
||||
calibrationDriver.release();
|
||||
calibrationDriver = null;
|
||||
}
|
||||
calibrationMode = false;
|
||||
cameraMode = false;
|
||||
if (audioCapture != null) {
|
||||
audioCapture.stop();
|
||||
audioCapture = null;
|
||||
@@ -720,28 +843,36 @@ public class ScreenCastService extends Service {
|
||||
audioEncoder.stop();
|
||||
audioEncoder = null;
|
||||
}
|
||||
if (videoEncoder != null) {
|
||||
videoEncoder.stop();
|
||||
videoEncoder = null;
|
||||
}
|
||||
if (projection != null) {
|
||||
projection.stop();
|
||||
projection = null;
|
||||
}
|
||||
if (multiCoordinator != null) {
|
||||
multiCoordinator.closeAll();
|
||||
multiCoordinator = null;
|
||||
multiCoordinator.sendGoodbyeAll();
|
||||
} else if (session != null) {
|
||||
try {
|
||||
session.send(CastProtocol.MSG_GOODBYE, new byte[0]);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
session.close();
|
||||
session = null;
|
||||
}
|
||||
if (sendPump != null) {
|
||||
sendPump.stop();
|
||||
sendPump = null;
|
||||
}
|
||||
if (fanoutPump != null) {
|
||||
fanoutPump.stop();
|
||||
fanoutPump = null;
|
||||
}
|
||||
if (sendPump != null) {
|
||||
sendPump.stop();
|
||||
sendPump = null;
|
||||
if (multiCoordinator != null) {
|
||||
multiCoordinator.closeAll();
|
||||
multiCoordinator = null;
|
||||
} else if (session != null) {
|
||||
session.close();
|
||||
session = null;
|
||||
}
|
||||
releaseWakeLock();
|
||||
}
|
||||
@@ -752,10 +883,17 @@ public class ScreenCastService extends Service {
|
||||
}
|
||||
|
||||
private void stopSelfSafely() {
|
||||
stopping.set(true);
|
||||
releaseAll();
|
||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||
stopSelf();
|
||||
if (!stopping.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
Thread stopper = new Thread(() -> {
|
||||
releaseAll();
|
||||
mainHandler.post(() -> {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||
stopSelf();
|
||||
});
|
||||
}, "CastStop");
|
||||
stopper.start();
|
||||
}
|
||||
|
||||
private void startSenderForeground(String text, boolean withAudio) {
|
||||
@@ -771,6 +909,41 @@ public class ScreenCastService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
private TransportLossStats buildSenderLossSnapshot() {
|
||||
TransportLossStats snap = session != null
|
||||
? session.getTransport().getLossStats() : new TransportLossStats();
|
||||
if (sendPump != null) {
|
||||
snap.sendDroppedVideoP = sendPump.getDroppedPFrames();
|
||||
snap.sendDroppedAudio = sendPump.getDroppedAudioFrames();
|
||||
snap.sendQueueDepth = sendPump.getQueueDepth();
|
||||
} else if (fanoutPump != null) {
|
||||
snap.sendDroppedVideoP = fanoutPump.getDroppedPFrames();
|
||||
snap.sendQueueDepth = fanoutPump.getQueueDepth();
|
||||
}
|
||||
return snap;
|
||||
}
|
||||
|
||||
private void finishSessionStatsAsync() {
|
||||
final SessionStatsRecorder recorder = sessionStatsRecorder;
|
||||
sessionStatsRecorder = null;
|
||||
if (recorder == null) {
|
||||
return;
|
||||
}
|
||||
if (activeSettings != null) {
|
||||
recorder.setResolution(captureWidth, captureHeight);
|
||||
recorder.setCaptureMode(activeSettings.getCaptureMode() != null
|
||||
? activeSettings.getCaptureMode().name() : "");
|
||||
}
|
||||
recorder.mergeLoss(buildSenderLossSnapshot());
|
||||
new Thread(() -> recorder.finish(getApplicationContext()), "SessionStatsSave").start();
|
||||
}
|
||||
|
||||
private void logEncodedCodecSample(String mime, byte[] frameData) {
|
||||
String probe = com.foxx.androidcast.media.EncodedStreamProbe.describeKeyframe(mime, frameData);
|
||||
Log.i(TAG, "Encoded stream: " + com.foxx.androidcast.media.CodecNegotiator.displayName(mime)
|
||||
+ " — " + probe);
|
||||
}
|
||||
|
||||
private void updateStatus(String text) {
|
||||
status = text;
|
||||
broadcastStatus(text);
|
||||
@@ -790,6 +963,90 @@ public class ScreenCastService extends Service {
|
||||
callbacks.finishBroadcast();
|
||||
}
|
||||
|
||||
private VideoEncoder.Callback buildVideoEncoderCallback(String mime) {
|
||||
return new VideoEncoder.Callback() {
|
||||
@Override
|
||||
public void onConfig(int w, int h, byte[] csd0, byte[] csd1) {
|
||||
if (stopping.get()) {
|
||||
return;
|
||||
}
|
||||
sendSafe(CastProtocol.MSG_STREAM_CONFIG,
|
||||
() -> CastProtocol.streamConfigPayload(w, h, csd0, csd1));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
|
||||
if ((sendPump == null && fanoutPump == null) || frameData == null) {
|
||||
return;
|
||||
}
|
||||
boolean tiny = effectiveParams != null && effectiveParams.skipTinyPFrames
|
||||
&& !keyFrame
|
||||
&& frameData.length < CastConfig.MIN_SIGNIFICANT_FRAME_BYTES;
|
||||
if (tiny) {
|
||||
return;
|
||||
}
|
||||
lastSignificantFrameMs = System.currentTimeMillis();
|
||||
senderMetrics.onEncodedFrame(keyFrame);
|
||||
if (keyFrame) {
|
||||
logEncodedCodecSample(mime, frameData);
|
||||
}
|
||||
enqueueVideoFrame(ptsUs, keyFrame, frameData);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void startCalibrationVideoEncoder(String mime, CastTuningEngine.EffectiveParams params,
|
||||
VideoEncoder.Callback videoCb, boolean tryGles) throws IOException {
|
||||
if (encoderKeepalive != null) {
|
||||
encoderKeepalive.stop();
|
||||
encoderKeepalive = null;
|
||||
}
|
||||
if (calibrationDriver != null) {
|
||||
calibrationDriver.release();
|
||||
calibrationDriver = null;
|
||||
}
|
||||
if (videoEncoder != null) {
|
||||
videoEncoder.stop();
|
||||
}
|
||||
videoEncoder = new VideoEncoder();
|
||||
float density = getResources().getDisplayMetrics().density;
|
||||
calibrationDriver = new com.foxx.androidcast.sender.calibration.CalibrationCastDriver(this, this);
|
||||
if (tryGles) {
|
||||
Surface surface = videoEncoder.prepare(captureWidth, captureHeight, mime, params, videoCb);
|
||||
encoderKeepalive = new EncoderKeepalive();
|
||||
encoderKeepalive.start(videoEncoder);
|
||||
calibrationDriver.attachSurface(videoEncoder, surface, captureWidth, captureHeight, density);
|
||||
videoEncoder.requestKeyframe();
|
||||
Log.i(TAG, "Calibration encoder (GLES try) " + captureWidth + "x" + captureHeight + " mime=" + mime);
|
||||
} else {
|
||||
videoEncoder.prepareBufferInput(captureWidth, captureHeight, mime, params, videoCb);
|
||||
encoderKeepalive = new EncoderKeepalive();
|
||||
encoderKeepalive.start(videoEncoder);
|
||||
calibrationDriver.attachBuffer(videoEncoder, captureWidth, captureHeight, density);
|
||||
videoEncoder.requestKeyframe();
|
||||
Log.i(TAG, "Calibration encoder (buffer) " + captureWidth + "x" + captureHeight + " mime=" + mime);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCalibrationNeedBufferEncoder() {
|
||||
if (!calibrationMode || stopping.get() || activeSettings == null) {
|
||||
return;
|
||||
}
|
||||
String mime = activeSettings.getNegotiatedVideoMime();
|
||||
if (mime == null || mime.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
mainHandler.post(() -> {
|
||||
try {
|
||||
startCalibrationVideoEncoder(mime, effectiveParams, buildVideoEncoderCallback(mime), false);
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Calibration buffer fallback failed", e);
|
||||
failAndStop("Calibration video failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return binder;
|
||||
|
||||
@@ -14,6 +14,7 @@ import android.provider.Settings;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.ListView;
|
||||
import android.widget.ProgressBar;
|
||||
@@ -35,7 +36,6 @@ import com.foxx.androidcast.discovery.DiscoveryManager;
|
||||
|
||||
public class SenderActivity extends AppCompatActivity {
|
||||
private static final int REQUEST_MEDIA_PROJECTION = 2001;
|
||||
private static final long DISCOVERY_INTERVAL_MS = 30_000;
|
||||
private static final long BACK_STOP_INTERVAL_MS = 2_000;
|
||||
private static final int DEV_TAP_UNLOCK = 7;
|
||||
|
||||
@@ -47,7 +47,28 @@ public class SenderActivity extends AppCompatActivity {
|
||||
private TextView statusText;
|
||||
private TextView titleText;
|
||||
private ProgressBar discoveryProgress;
|
||||
private Button refreshButton;
|
||||
private View advancedPanel;
|
||||
|
||||
private enum DiscoveryPhase {
|
||||
IDLE, SCANNING, PAUSED
|
||||
}
|
||||
|
||||
private DiscoveryPhase discoveryPhase = DiscoveryPhase.IDLE;
|
||||
private int discoverySecondsLeft;
|
||||
private final Runnable autoScanRunnable = () -> beginScanPass(false);
|
||||
private final Runnable discoveryCountdownTick = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (discoveryPhase == DiscoveryPhase.SCANNING || discoveryPhase == DiscoveryPhase.PAUSED) {
|
||||
if (discoverySecondsLeft > 0) {
|
||||
discoverySecondsLeft--;
|
||||
}
|
||||
updateRefreshButtonLabel();
|
||||
handler.postDelayed(this, 1000);
|
||||
}
|
||||
}
|
||||
};
|
||||
private EditText pinInput;
|
||||
private EditText manualHostInput;
|
||||
private EditText manualPortInput;
|
||||
@@ -57,15 +78,6 @@ public class SenderActivity extends AppCompatActivity {
|
||||
private boolean useManualConnect;
|
||||
private long lastBackPressMs;
|
||||
|
||||
private final Runnable discoveryWatchdog = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (discovery != null) {
|
||||
restartDiscoveryInternal(false);
|
||||
}
|
||||
handler.postDelayed(this, DISCOVERY_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
@@ -79,6 +91,7 @@ public class SenderActivity extends AppCompatActivity {
|
||||
pinInput.setText(CastConfig.DEFAULT_PIN);
|
||||
statusText = findViewById(R.id.text_status);
|
||||
discoveryProgress = findViewById(R.id.progress_discovery);
|
||||
refreshButton = findViewById(R.id.btn_refresh);
|
||||
advancedPanel = findViewById(R.id.panel_advanced);
|
||||
manualHostInput = findViewById(R.id.edit_manual_host);
|
||||
manualPortInput = findViewById(R.id.edit_manual_port);
|
||||
@@ -143,12 +156,22 @@ public class SenderActivity extends AppCompatActivity {
|
||||
|
||||
@Override
|
||||
public void onScanStarted() {
|
||||
discoveryPhase = DiscoveryPhase.SCANNING;
|
||||
discoverySecondsLeft = (int) (CastConfig.DISCOVERY_SCAN_PASS_MS / 1000);
|
||||
updateRefreshButtonLabel();
|
||||
handler.removeCallbacks(discoveryCountdownTick);
|
||||
handler.post(discoveryCountdownTick);
|
||||
showDiscoveryProgress();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onScanEnded() {
|
||||
hideDiscoveryProgress();
|
||||
discoveryPhase = DiscoveryPhase.PAUSED;
|
||||
discoverySecondsLeft = (int) (CastConfig.DISCOVERY_PAUSE_MS / 1000);
|
||||
updateRefreshButtonLabel();
|
||||
handler.removeCallbacks(autoScanRunnable);
|
||||
handler.postDelayed(autoScanRunnable, CastConfig.DISCOVERY_PAUSE_MS);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -170,7 +193,7 @@ public class SenderActivity extends AppCompatActivity {
|
||||
}
|
||||
});
|
||||
|
||||
findViewById(R.id.btn_refresh).setOnClickListener(v -> restartDiscovery());
|
||||
refreshButton.setOnClickListener(v -> onRefreshClicked());
|
||||
findViewById(R.id.btn_start_cast).setOnClickListener(v ->
|
||||
PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow));
|
||||
findViewById(R.id.btn_manual_cast).setOnClickListener(v -> {
|
||||
@@ -188,36 +211,60 @@ public class SenderActivity extends AppCompatActivity {
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
restartDiscovery();
|
||||
handler.postDelayed(discoveryWatchdog, DISCOVERY_INTERVAL_MS);
|
||||
beginScanPass(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
handler.removeCallbacks(discoveryWatchdog);
|
||||
handler.removeCallbacks(discoveryCountdownTick);
|
||||
handler.removeCallbacks(autoScanRunnable);
|
||||
if (discovery != null) {
|
||||
discovery.stop();
|
||||
}
|
||||
discoveryPhase = DiscoveryPhase.IDLE;
|
||||
updateRefreshButtonLabel();
|
||||
hideDiscoveryProgress();
|
||||
super.onStop();
|
||||
}
|
||||
|
||||
private void restartDiscovery() {
|
||||
handler.removeCallbacks(discoveryWatchdog);
|
||||
restartDiscoveryInternal(true);
|
||||
handler.postDelayed(discoveryWatchdog, DISCOVERY_INTERVAL_MS);
|
||||
private void onRefreshClicked() {
|
||||
if (discoveryPhase == DiscoveryPhase.SCANNING) {
|
||||
return;
|
||||
}
|
||||
handler.removeCallbacks(autoScanRunnable);
|
||||
beginScanPass(true);
|
||||
}
|
||||
|
||||
private void restartDiscoveryInternal(boolean userRefresh) {
|
||||
deviceAdapter.clearDevices();
|
||||
statusText.setText(userRefresh ? R.string.rescanning : R.string.searching);
|
||||
private void beginScanPass(boolean userInitiated) {
|
||||
if (discovery != null && discovery.isBrowsing()) {
|
||||
return;
|
||||
}
|
||||
if (userInitiated) {
|
||||
deviceAdapter.clearDevices();
|
||||
statusText.setText(R.string.rescanning);
|
||||
} else if (deviceAdapter.getCount() == 0) {
|
||||
statusText.setText(R.string.searching);
|
||||
}
|
||||
if (discovery != null) {
|
||||
discovery.stop();
|
||||
discovery.clearDevices();
|
||||
if (userInitiated) {
|
||||
discovery.clearDevices();
|
||||
}
|
||||
}
|
||||
discovery.startBrowsing();
|
||||
}
|
||||
|
||||
private void updateRefreshButtonLabel() {
|
||||
if (refreshButton == null) {
|
||||
return;
|
||||
}
|
||||
if (discoveryPhase == DiscoveryPhase.SCANNING || discoveryPhase == DiscoveryPhase.PAUSED) {
|
||||
refreshButton.setText(getString(R.string.refresh_countdown, Math.max(0, discoverySecondsLeft)));
|
||||
} else {
|
||||
refreshButton.setText(R.string.refresh);
|
||||
}
|
||||
}
|
||||
|
||||
private void showDiscoveryProgress() {
|
||||
discoveryProgress.setVisibility(View.VISIBLE);
|
||||
discoveryProgress.bringToFront();
|
||||
@@ -303,6 +350,21 @@ public class SenderActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
pendingPin = pin;
|
||||
if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST) {
|
||||
launchCastService(null, Activity.RESULT_OK);
|
||||
return;
|
||||
}
|
||||
if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CAMERA) {
|
||||
if (androidx.core.content.ContextCompat.checkSelfPermission(this,
|
||||
android.Manifest.permission.CAMERA) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||
androidx.core.app.ActivityCompat.requestPermissions(this,
|
||||
new String[] {android.Manifest.permission.CAMERA}, PermissionHelper.REQUEST_CODE);
|
||||
pendingCameraLaunch = true;
|
||||
return;
|
||||
}
|
||||
launchCastService(null, Activity.RESULT_OK);
|
||||
return;
|
||||
}
|
||||
MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
|
||||
startActivityForResult(createScreenCaptureIntent(mgr), REQUEST_MEDIA_PROJECTION);
|
||||
}
|
||||
@@ -316,39 +378,61 @@ public class SenderActivity extends AppCompatActivity {
|
||||
return mgr.createScreenCaptureIntent();
|
||||
}
|
||||
|
||||
private boolean pendingCameraLaunch;
|
||||
|
||||
private String pendingPin;
|
||||
private String pendingHost;
|
||||
private int pendingPort;
|
||||
private String pendingDeviceName;
|
||||
private java.util.ArrayList<CastReceiverTarget> pendingTargets;
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
if (requestCode == PermissionHelper.REQUEST_CODE && pendingCameraLaunch) {
|
||||
pendingCameraLaunch = false;
|
||||
if (grantResults.length > 0 && grantResults[0] == android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||
launchCastService(null, Activity.RESULT_OK);
|
||||
} else {
|
||||
Toast.makeText(this, R.string.capture_camera_not_implemented, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (requestCode != REQUEST_MEDIA_PROJECTION) {
|
||||
return;
|
||||
}
|
||||
if (resultCode != Activity.RESULT_OK || data == null || pendingHost == null) {
|
||||
if (resultCode != Activity.RESULT_OK || pendingHost == null) {
|
||||
Toast.makeText(this, R.string.capture_denied, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
launchCastService(data, resultCode);
|
||||
}
|
||||
|
||||
private void launchCastService(Intent projectionData, int resultCode) {
|
||||
String deviceName = Settings.Global.getString(getContentResolver(), Settings.Global.DEVICE_NAME);
|
||||
if (deviceName == null) {
|
||||
deviceName = Build.MODEL;
|
||||
}
|
||||
|
||||
Intent serviceIntent = new Intent(this, ScreenCastService.class);
|
||||
serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_CODE, resultCode);
|
||||
serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_DATA, new Intent(data));
|
||||
if (projectionData != null) {
|
||||
serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_DATA, new Intent(projectionData));
|
||||
}
|
||||
serviceIntent.putExtra(ScreenCastService.EXTRA_HOST, pendingHost);
|
||||
serviceIntent.putExtra(ScreenCastService.EXTRA_PORT, pendingPort);
|
||||
serviceIntent.putExtra(ScreenCastService.EXTRA_PIN, pendingPin);
|
||||
serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, pendingDeviceName != null ? pendingDeviceName : deviceName);
|
||||
serviceIntent.putExtra(CastSettings.EXTRA, castSettings);
|
||||
if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST) {
|
||||
serviceIntent.putExtra(ScreenCastService.EXTRA_CALIBRATION, true);
|
||||
}
|
||||
if (pendingTargets != null && !pendingTargets.isEmpty()) {
|
||||
serviceIntent.putExtra(ScreenCastService.EXTRA_TARGETS, pendingTargets);
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(serviceIntent);
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.foxx.androidcast.sender;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.media.MediaCodec;
|
||||
import android.media.MediaCodecInfo;
|
||||
import android.media.MediaFormat;
|
||||
@@ -9,6 +10,7 @@ import android.util.Log;
|
||||
import android.view.Surface;
|
||||
|
||||
import com.foxx.androidcast.CastTuningEngine;
|
||||
import com.foxx.androidcast.media.RgbToYuv420;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
@@ -25,6 +27,9 @@ public class VideoEncoder {
|
||||
|
||||
private MediaCodec codec;
|
||||
private Surface inputSurface;
|
||||
private boolean bufferInput;
|
||||
private RgbToYuv420.Layout yuvLayout = RgbToYuv420.Layout.I420;
|
||||
private byte[] yuvScratch;
|
||||
private Callback callback;
|
||||
private int width;
|
||||
private int height;
|
||||
@@ -32,7 +37,10 @@ public class VideoEncoder {
|
||||
private Thread drainThread;
|
||||
private String mime;
|
||||
|
||||
/** Request an IDR (keyframe) on the next opportunity. */
|
||||
public Surface getInputSurface() {
|
||||
return inputSurface;
|
||||
}
|
||||
|
||||
public void requestKeyframe() {
|
||||
if (codec == null || !running) {
|
||||
return;
|
||||
@@ -48,19 +56,36 @@ public class VideoEncoder {
|
||||
|
||||
public Surface prepare(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
|
||||
Callback callback) throws IOException {
|
||||
return prepareInternal(width, height, mime, params, callback, false);
|
||||
}
|
||||
|
||||
/** Software-fed frames (calibration chart) via YUV buffers — no input Surface. */
|
||||
public void prepareBufferInput(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
|
||||
Callback callback) throws IOException {
|
||||
prepareInternal(width, height, mime, params, callback, true);
|
||||
}
|
||||
|
||||
private Surface prepareInternal(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
|
||||
Callback callback, boolean buffer) throws IOException {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.mime = mime;
|
||||
this.callback = callback;
|
||||
|
||||
MediaFormat tuned = buildFormat(width, height, mime, params, true);
|
||||
MediaFormat basic = buildFormat(width, height, mime, params, false);
|
||||
this.bufferInput = buffer;
|
||||
yuvScratch = buffer ? new byte[RgbToYuv420.bufferSize(width, height)] : null;
|
||||
|
||||
codec = MediaCodec.createEncoderByType(mime);
|
||||
yuvLayout = buffer ? pickYuvLayout(codec, mime) : RgbToYuv420.Layout.I420;
|
||||
|
||||
MediaFormat tuned = buildFormat(width, height, mime, params, true, buffer, yuvLayout);
|
||||
MediaFormat basic = buildFormat(width, height, mime, params, false, buffer, yuvLayout);
|
||||
|
||||
if (!tryConfigure(codec, tuned)) {
|
||||
Log.w(TAG, "Tuned encoder format rejected; retrying with basic format");
|
||||
codec.release();
|
||||
codec = MediaCodec.createEncoderByType(mime);
|
||||
yuvLayout = buffer ? pickYuvLayout(codec, mime) : RgbToYuv420.Layout.I420;
|
||||
basic = buildFormat(width, height, mime, params, false, buffer, yuvLayout);
|
||||
if (!tryConfigure(codec, basic)) {
|
||||
codec.release();
|
||||
codec = null;
|
||||
@@ -68,20 +93,73 @@ public class VideoEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
inputSurface = codec.createInputSurface();
|
||||
if (bufferInput) {
|
||||
inputSurface = null;
|
||||
} else {
|
||||
inputSurface = codec.createInputSurface();
|
||||
}
|
||||
codec.start();
|
||||
running = true;
|
||||
drainThread = new Thread(this::drainLoop, "VideoEncoderDrain");
|
||||
drainThread.start();
|
||||
Log.i(TAG, "Encoder " + mime + " " + width + "x" + height + " @ " + params.videoFps + "fps "
|
||||
+ (params.videoBitrate / 1000) + "kbps GOP=" + params.keyframeIntervalSec);
|
||||
Log.i(TAG, (bufferInput ? "Buffer/" + yuvLayout : "Surface") + " encoder " + mime
|
||||
+ " " + width + "x" + height + " @ " + params.videoFps + "fps");
|
||||
return inputSurface;
|
||||
}
|
||||
|
||||
private static RgbToYuv420.Layout pickYuvLayout(MediaCodec codec, String mime) {
|
||||
MediaCodecInfo.CodecCapabilities caps = codec.getCodecInfo().getCapabilitiesForType(mime);
|
||||
for (int fmt : caps.colorFormats) {
|
||||
if (fmt == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar) {
|
||||
return RgbToYuv420.Layout.NV12;
|
||||
}
|
||||
}
|
||||
return RgbToYuv420.Layout.I420;
|
||||
}
|
||||
|
||||
public void queueBitmapFrame(Bitmap bitmap, long ptsUs) {
|
||||
if (!bufferInput || codec == null || !running || bitmap == null) {
|
||||
return;
|
||||
}
|
||||
byte[] yuv = RgbToYuv420.fromBitmap(bitmap, yuvLayout);
|
||||
queueYuvFrame(yuv, ptsUs);
|
||||
}
|
||||
|
||||
public void queueYuvFrame(byte[] yuv, long ptsUs) {
|
||||
if (!bufferInput || codec == null || !running || yuv == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
int index = codec.dequeueInputBuffer(0);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
ByteBuffer buffer = codec.getInputBuffer(index);
|
||||
if (buffer == null) {
|
||||
return;
|
||||
}
|
||||
buffer.clear();
|
||||
int len = Math.min(buffer.remaining(), yuv.length);
|
||||
buffer.put(yuv, 0, len);
|
||||
codec.queueInputBuffer(index, 0, len, ptsUs, 0);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "queueYuvFrame: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static MediaFormat buildFormat(int width, int height, String mime,
|
||||
CastTuningEngine.EffectiveParams params, boolean tuned) {
|
||||
CastTuningEngine.EffectiveParams params, boolean tuned, boolean buffer,
|
||||
RgbToYuv420.Layout layout) {
|
||||
MediaFormat format = MediaFormat.createVideoFormat(mime, width, height);
|
||||
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
|
||||
int color;
|
||||
if (!buffer) {
|
||||
color = MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface;
|
||||
} else if (layout == RgbToYuv420.Layout.NV12) {
|
||||
color = MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar;
|
||||
} else {
|
||||
color = MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar;
|
||||
}
|
||||
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, color);
|
||||
format.setInteger(MediaFormat.KEY_BIT_RATE, params.videoBitrate);
|
||||
format.setInteger(MediaFormat.KEY_FRAME_RATE, params.videoFps);
|
||||
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, params.keyframeIntervalSec);
|
||||
@@ -117,6 +195,7 @@ public class VideoEncoder {
|
||||
|
||||
public void stop() {
|
||||
running = false;
|
||||
bufferInput = false;
|
||||
if (drainThread != null) {
|
||||
try {
|
||||
drainThread.join(1500);
|
||||
@@ -136,25 +215,30 @@ public class VideoEncoder {
|
||||
inputSurface.release();
|
||||
inputSurface = null;
|
||||
}
|
||||
yuvScratch = null;
|
||||
}
|
||||
|
||||
private void drainLoop() {
|
||||
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
||||
boolean configSent = false;
|
||||
while (running) {
|
||||
int index = codec.dequeueOutputBuffer(info, 5_000);
|
||||
MediaCodec c = codec;
|
||||
if (c == null) {
|
||||
break;
|
||||
}
|
||||
int index = c.dequeueOutputBuffer(info, 5_000);
|
||||
if (index == MediaCodec.INFO_TRY_AGAIN_LATER) {
|
||||
continue;
|
||||
}
|
||||
if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
||||
sendConfig(codec.getOutputFormat(), configSent);
|
||||
sendConfig(c.getOutputFormat(), configSent);
|
||||
configSent = true;
|
||||
continue;
|
||||
}
|
||||
if (index < 0) {
|
||||
continue;
|
||||
}
|
||||
ByteBuffer buffer = codec.getOutputBuffer(index);
|
||||
ByteBuffer buffer = c.getOutputBuffer(index);
|
||||
if (buffer != null && info.size > 0) {
|
||||
byte[] data = new byte[info.size];
|
||||
buffer.position(info.offset);
|
||||
@@ -166,7 +250,7 @@ public class VideoEncoder {
|
||||
callback.onEncodedFrame(info.presentationTimeUs, keyFrame, data);
|
||||
}
|
||||
}
|
||||
codec.releaseOutputBuffer(index, false);
|
||||
c.releaseOutputBuffer(index, false);
|
||||
if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.foxx.androidcast.sender.calibration;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.os.Handler;
|
||||
import android.os.HandlerThread;
|
||||
import android.util.Log;
|
||||
import android.view.Surface;
|
||||
|
||||
import com.foxx.androidcast.media.PcmLevelAnalyzer;
|
||||
import com.foxx.androidcast.sender.VideoEncoder;
|
||||
|
||||
/**
|
||||
* Renders calibration chart + VU border into the video encoder (GLES surface or YUV buffer fallback).
|
||||
*/
|
||||
public final class CalibrationCastDriver {
|
||||
private static final String TAG = "CalibrationCastDriver";
|
||||
private static final int EGL_FAIL_THRESHOLD = 5;
|
||||
|
||||
public interface FallbackHost {
|
||||
/** Recreate encoder with {@link VideoEncoder#prepareBufferInput} after GLES failure. */
|
||||
void onCalibrationNeedBufferEncoder();
|
||||
}
|
||||
|
||||
private enum FeedMode {
|
||||
GLES_SURFACE,
|
||||
YUV_BUFFER
|
||||
}
|
||||
|
||||
private final Context appContext;
|
||||
private final HandlerThread thread;
|
||||
private final Handler handler;
|
||||
private final FallbackHost fallbackHost;
|
||||
private CalibrationEglRenderer eglRenderer;
|
||||
private VideoEncoder videoEncoder;
|
||||
private Bitmap chartBitmap;
|
||||
private Bitmap frameBitmap;
|
||||
private Canvas frameCanvas;
|
||||
private int width;
|
||||
private int height;
|
||||
private float density;
|
||||
private int scrollPx;
|
||||
private volatile float vuLevel;
|
||||
private volatile boolean running;
|
||||
private Runnable tick;
|
||||
private long encodeStartNs;
|
||||
private FeedMode feedMode = FeedMode.YUV_BUFFER;
|
||||
private int eglFailures;
|
||||
private boolean fallbackRequested;
|
||||
|
||||
public CalibrationCastDriver(Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public CalibrationCastDriver(Context context, FallbackHost fallbackHost) {
|
||||
appContext = context.getApplicationContext();
|
||||
this.fallbackHost = fallbackHost;
|
||||
thread = new HandlerThread("CalibrationCast");
|
||||
thread.start();
|
||||
handler = new Handler(thread.getLooper());
|
||||
}
|
||||
|
||||
/** Preferred path: GLES into encoder {@link Surface}. */
|
||||
public void attachSurface(VideoEncoder encoder, Surface encoderSurface, int width, int height, float density) {
|
||||
this.videoEncoder = encoder;
|
||||
initBitmaps(width, height, density);
|
||||
eglFailures = 0;
|
||||
fallbackRequested = false;
|
||||
try {
|
||||
eglRenderer = new CalibrationEglRenderer();
|
||||
eglRenderer.start(encoderSurface, width, height);
|
||||
feedMode = FeedMode.GLES_SURFACE;
|
||||
Log.i(TAG, "Calibration GLES " + width + "x" + height);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "GLES init failed: " + e.getMessage());
|
||||
releaseEgl();
|
||||
feedMode = FeedMode.YUV_BUFFER;
|
||||
requestBufferEncoderFallback();
|
||||
}
|
||||
running = true;
|
||||
encodeStartNs = System.nanoTime();
|
||||
startTick();
|
||||
}
|
||||
|
||||
/** Reliable path when GLES cannot use the encoder surface (e.g. EGL_BAD_ACCESS on MTK). */
|
||||
public void attachBuffer(VideoEncoder encoder, int width, int height, float density) {
|
||||
this.videoEncoder = encoder;
|
||||
releaseEgl();
|
||||
feedMode = FeedMode.YUV_BUFFER;
|
||||
initBitmaps(width, height, density);
|
||||
running = true;
|
||||
encodeStartNs = System.nanoTime();
|
||||
startTick();
|
||||
Log.i(TAG, "Calibration buffer feed " + width + "x" + height);
|
||||
}
|
||||
|
||||
private void initBitmaps(int w, int h, float density) {
|
||||
this.width = w;
|
||||
this.height = h;
|
||||
this.density = density;
|
||||
scrollPx = 0;
|
||||
chartBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
|
||||
Canvas chartCanvas = new Canvas(chartBitmap);
|
||||
TvCalibrationGenerator.drawChart(chartCanvas, w, h, appContext);
|
||||
frameBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
|
||||
frameCanvas = new Canvas(frameBitmap);
|
||||
}
|
||||
|
||||
public void setVuFromPcm(byte[] pcm, int length) {
|
||||
PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(pcm, length);
|
||||
float instant = Math.max(levels.peak, levels.rms);
|
||||
if (levels.silent) {
|
||||
vuLevel *= 0.88f;
|
||||
} else {
|
||||
vuLevel = instant * 0.7f + vuLevel * 0.3f;
|
||||
}
|
||||
}
|
||||
|
||||
private void startTick() {
|
||||
tick = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (!running || frameCanvas == null) {
|
||||
return;
|
||||
}
|
||||
long frameStart = System.nanoTime();
|
||||
drawFrame();
|
||||
scrollPx += Math.max(1, Math.round(1f * density));
|
||||
long elapsedMs = (System.nanoTime() - frameStart) / 1_000_000L;
|
||||
long delay = feedMode == FeedMode.GLES_SURFACE
|
||||
? Math.max(1L, 33L - elapsedMs)
|
||||
: Math.max(1L, 50L - elapsedMs);
|
||||
handler.postDelayed(this, delay);
|
||||
}
|
||||
};
|
||||
handler.post(tick);
|
||||
}
|
||||
|
||||
private void drawFrame() {
|
||||
frameCanvas.drawBitmap(chartBitmap, 0f, 0f, null);
|
||||
TvCalibrationGenerator.drawBorderOverlay(
|
||||
frameCanvas, width, height, density, scrollPx, vuLevel);
|
||||
long ptsUs = (System.nanoTime() - encodeStartNs) / 1000L;
|
||||
if (feedMode == FeedMode.GLES_SURFACE && eglRenderer != null && eglRenderer.isReady()) {
|
||||
if (eglRenderer.draw(frameBitmap, System.nanoTime())) {
|
||||
eglFailures = 0;
|
||||
return;
|
||||
}
|
||||
eglFailures++;
|
||||
if (eglFailures >= EGL_FAIL_THRESHOLD) {
|
||||
Log.w(TAG, "GLES feed failed " + eglFailures + " times — switching to buffer");
|
||||
releaseEgl();
|
||||
feedMode = FeedMode.YUV_BUFFER;
|
||||
requestBufferEncoderFallback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (videoEncoder != null && feedMode == FeedMode.YUV_BUFFER) {
|
||||
videoEncoder.queueBitmapFrame(frameBitmap, ptsUs);
|
||||
}
|
||||
}
|
||||
|
||||
private void requestBufferEncoderFallback() {
|
||||
if (fallbackRequested || fallbackHost == null) {
|
||||
return;
|
||||
}
|
||||
fallbackRequested = true;
|
||||
handler.post(() -> fallbackHost.onCalibrationNeedBufferEncoder());
|
||||
}
|
||||
|
||||
private void releaseEgl() {
|
||||
if (eglRenderer != null) {
|
||||
eglRenderer.release();
|
||||
eglRenderer = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
running = false;
|
||||
handler.removeCallbacksAndMessages(null);
|
||||
releaseEgl();
|
||||
if (chartBitmap != null) {
|
||||
chartBitmap.recycle();
|
||||
chartBitmap = null;
|
||||
}
|
||||
if (frameBitmap != null) {
|
||||
frameBitmap.recycle();
|
||||
frameBitmap = null;
|
||||
}
|
||||
frameCanvas = null;
|
||||
videoEncoder = null;
|
||||
}
|
||||
|
||||
public void release() {
|
||||
stop();
|
||||
thread.quitSafely();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package com.foxx.androidcast.sender.calibration;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.opengl.EGL14;
|
||||
import android.opengl.EGLConfig;
|
||||
import android.opengl.EGLContext;
|
||||
import android.opengl.EGLDisplay;
|
||||
import android.opengl.EGLExt;
|
||||
import android.opengl.EGLSurface;
|
||||
import android.opengl.GLES20;
|
||||
import android.opengl.GLUtils;
|
||||
import android.util.Log;
|
||||
import android.view.Surface;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.FloatBuffer;
|
||||
|
||||
/** Draws a bitmap into a MediaCodec encoder input {@link Surface} via GLES2. */
|
||||
final class CalibrationEglRenderer {
|
||||
private static final String TAG = "CalibrationEglRenderer";
|
||||
|
||||
private static final String VERT =
|
||||
"attribute vec4 aPosition;\n"
|
||||
+ "attribute vec2 aTexCoord;\n"
|
||||
+ "varying vec2 vTexCoord;\n"
|
||||
+ "void main() {\n"
|
||||
+ " gl_Position = aPosition;\n"
|
||||
+ " vTexCoord = aTexCoord;\n"
|
||||
+ "}\n";
|
||||
|
||||
private static final String FRAG =
|
||||
"precision mediump float;\n"
|
||||
+ "varying vec2 vTexCoord;\n"
|
||||
+ "uniform sampler2D uTexture;\n"
|
||||
+ "void main() {\n"
|
||||
+ " gl_FragColor = texture2D(uTexture, vTexCoord);\n"
|
||||
+ "}\n";
|
||||
|
||||
private static final float[] QUAD = {
|
||||
-1f, -1f, 0f, 1f,
|
||||
1f, -1f, 1f, 1f,
|
||||
-1f, 1f, 0f, 0f,
|
||||
1f, 1f, 1f, 0f,
|
||||
};
|
||||
|
||||
private EGLDisplay eglDisplay = EGL14.EGL_NO_DISPLAY;
|
||||
private EGLContext eglContext = EGL14.EGL_NO_CONTEXT;
|
||||
private EGLSurface eglSurface = EGL14.EGL_NO_SURFACE;
|
||||
private int program;
|
||||
private int textureId;
|
||||
private int aPosition;
|
||||
private int aTexCoord;
|
||||
private int uTexture;
|
||||
private FloatBuffer quadBuffer;
|
||||
private int width;
|
||||
private int height;
|
||||
/** {@link EGLExt#EGL_RECORDABLE_ANDROID} — required for MediaCodec input surfaces. */
|
||||
private static final int EGL_RECORDABLE_ANDROID = 0x3142;
|
||||
|
||||
boolean isReady() {
|
||||
return eglDisplay != EGL14.EGL_NO_DISPLAY && eglSurface != EGL14.EGL_NO_SURFACE;
|
||||
}
|
||||
|
||||
void start(Surface surface, int width, int height) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY);
|
||||
if (eglDisplay == EGL14.EGL_NO_DISPLAY) {
|
||||
throw new IllegalStateException("eglGetDisplay failed");
|
||||
}
|
||||
int[] version = new int[2];
|
||||
if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) {
|
||||
throw new IllegalStateException("eglInitialize failed");
|
||||
}
|
||||
EGLConfig eglConfig = chooseConfig(eglDisplay);
|
||||
int[] contextAttribs = {EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE};
|
||||
eglContext = EGL14.eglCreateContext(eglDisplay, eglConfig, EGL14.EGL_NO_CONTEXT, contextAttribs, 0);
|
||||
if (eglContext == EGL14.EGL_NO_CONTEXT) {
|
||||
throw new IllegalStateException("eglCreateContext failed");
|
||||
}
|
||||
int[] surfaceAttribs = {EGL14.EGL_NONE};
|
||||
eglSurface = EGL14.eglCreateWindowSurface(eglDisplay, eglConfig, surface, surfaceAttribs, 0);
|
||||
if (eglSurface == EGL14.EGL_NO_SURFACE) {
|
||||
throw new IllegalStateException("eglCreateWindowSurface failed");
|
||||
}
|
||||
if (!makeCurrent()) {
|
||||
throw new IllegalStateException("eglMakeCurrent failed: 0x"
|
||||
+ Integer.toHexString(EGL14.eglGetError()));
|
||||
}
|
||||
program = buildProgram(VERT, FRAG);
|
||||
aPosition = GLES20.glGetAttribLocation(program, "aPosition");
|
||||
aTexCoord = GLES20.glGetAttribLocation(program, "aTexCoord");
|
||||
uTexture = GLES20.glGetUniformLocation(program, "uTexture");
|
||||
int[] textures = new int[1];
|
||||
GLES20.glGenTextures(1, textures, 0);
|
||||
textureId = textures[0];
|
||||
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
|
||||
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
|
||||
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
|
||||
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
|
||||
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
|
||||
quadBuffer = ByteBuffer.allocateDirect(QUAD.length * 4).order(ByteOrder.nativeOrder()).asFloatBuffer();
|
||||
quadBuffer.put(QUAD).position(0);
|
||||
GLES20.glViewport(0, 0, width, height);
|
||||
Log.i(TAG, "EGL calibration renderer " + width + "x" + height);
|
||||
}
|
||||
|
||||
/** @return false if EGL could not present this frame (caller should fall back to buffer input). */
|
||||
boolean draw(Bitmap bitmap, long ptsNs) {
|
||||
if (eglDisplay == EGL14.EGL_NO_DISPLAY || bitmap == null) {
|
||||
return false;
|
||||
}
|
||||
if (!makeCurrent()) {
|
||||
return false;
|
||||
}
|
||||
GLES20.glViewport(0, 0, width, height);
|
||||
GLES20.glClearColor(0f, 0f, 0f, 1f);
|
||||
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
|
||||
GLES20.glUseProgram(program);
|
||||
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
|
||||
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);
|
||||
GLES20.glUniform1i(uTexture, 0);
|
||||
quadBuffer.position(0);
|
||||
GLES20.glVertexAttribPointer(aPosition, 2, GLES20.GL_FLOAT, false, 16, quadBuffer);
|
||||
quadBuffer.position(2);
|
||||
GLES20.glVertexAttribPointer(aTexCoord, 2, GLES20.GL_FLOAT, false, 16, quadBuffer);
|
||||
GLES20.glEnableVertexAttribArray(aPosition);
|
||||
GLES20.glEnableVertexAttribArray(aTexCoord);
|
||||
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
|
||||
GLES20.glDisableVertexAttribArray(aPosition);
|
||||
GLES20.glDisableVertexAttribArray(aTexCoord);
|
||||
EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, ptsNs);
|
||||
if (!EGL14.eglSwapBuffers(eglDisplay, eglSurface)) {
|
||||
Log.w(TAG, "eglSwapBuffers failed: 0x" + Integer.toHexString(EGL14.eglGetError()));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean makeCurrent() {
|
||||
if (eglContext.equals(EGL14.eglGetCurrentContext())
|
||||
&& eglSurface.equals(EGL14.eglGetCurrentSurface(EGL14.EGL_DRAW))) {
|
||||
return true;
|
||||
}
|
||||
if (!EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)) {
|
||||
Log.w(TAG, "eglMakeCurrent failed: 0x" + Integer.toHexString(EGL14.eglGetError()));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void release() {
|
||||
if (textureId != 0) {
|
||||
int[] t = new int[] {textureId};
|
||||
GLES20.glDeleteTextures(1, t, 0);
|
||||
textureId = 0;
|
||||
}
|
||||
if (program != 0) {
|
||||
GLES20.glDeleteProgram(program);
|
||||
program = 0;
|
||||
}
|
||||
if (eglDisplay != EGL14.EGL_NO_DISPLAY) {
|
||||
EGL14.eglMakeCurrent(eglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT);
|
||||
if (eglSurface != EGL14.EGL_NO_SURFACE) {
|
||||
EGL14.eglDestroySurface(eglDisplay, eglSurface);
|
||||
}
|
||||
if (eglContext != EGL14.EGL_NO_CONTEXT) {
|
||||
EGL14.eglDestroyContext(eglDisplay, eglContext);
|
||||
}
|
||||
EGL14.eglTerminate(eglDisplay);
|
||||
}
|
||||
eglDisplay = EGL14.EGL_NO_DISPLAY;
|
||||
eglContext = EGL14.EGL_NO_CONTEXT;
|
||||
eglSurface = EGL14.EGL_NO_SURFACE;
|
||||
}
|
||||
|
||||
private static EGLConfig chooseConfig(EGLDisplay display) {
|
||||
int[] attribList = {
|
||||
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
|
||||
EGL14.EGL_RED_SIZE, 8,
|
||||
EGL14.EGL_GREEN_SIZE, 8,
|
||||
EGL14.EGL_BLUE_SIZE, 8,
|
||||
EGL14.EGL_ALPHA_SIZE, 8,
|
||||
EGL_RECORDABLE_ANDROID, 1,
|
||||
EGL14.EGL_NONE
|
||||
};
|
||||
EGLConfig[] configs = new EGLConfig[16];
|
||||
int[] numConfigs = new int[1];
|
||||
if (!EGL14.eglChooseConfig(display, attribList, 0, configs, 0, configs.length, numConfigs, 0)
|
||||
|| numConfigs[0] <= 0) {
|
||||
throw new IllegalStateException("eglChooseConfig failed (recordable)");
|
||||
}
|
||||
for (int i = 0; i < numConfigs[0]; i++) {
|
||||
int[] recordable = new int[1];
|
||||
if (EGL14.eglGetConfigAttrib(display, configs[i], EGL_RECORDABLE_ANDROID, recordable, 0)
|
||||
&& recordable[0] != 0) {
|
||||
return configs[i];
|
||||
}
|
||||
}
|
||||
return configs[0];
|
||||
}
|
||||
|
||||
private static int buildProgram(String vertexSource, String fragmentSource) {
|
||||
int vs = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource);
|
||||
int fs = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource);
|
||||
int program = GLES20.glCreateProgram();
|
||||
GLES20.glAttachShader(program, vs);
|
||||
GLES20.glAttachShader(program, fs);
|
||||
GLES20.glLinkProgram(program);
|
||||
int[] link = new int[1];
|
||||
GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, link, 0);
|
||||
if (link[0] == 0) {
|
||||
String err = GLES20.glGetProgramInfoLog(program);
|
||||
GLES20.glDeleteProgram(program);
|
||||
throw new IllegalStateException("Program link failed: " + err);
|
||||
}
|
||||
GLES20.glDeleteShader(vs);
|
||||
GLES20.glDeleteShader(fs);
|
||||
return program;
|
||||
}
|
||||
|
||||
private static int compileShader(int type, String source) {
|
||||
int shader = GLES20.glCreateShader(type);
|
||||
GLES20.glShaderSource(shader, source);
|
||||
GLES20.glCompileShader(shader);
|
||||
int[] compiled = new int[1];
|
||||
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0);
|
||||
if (compiled[0] == 0) {
|
||||
String err = GLES20.glGetShaderInfoLog(shader);
|
||||
GLES20.glDeleteShader(shader);
|
||||
throw new IllegalStateException("Shader compile failed: " + err);
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.foxx.androidcast.sender.calibration;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.RectF;
|
||||
|
||||
import com.foxx.androidcast.R;
|
||||
|
||||
/**
|
||||
* Draws a TV calibration chart (color bars + pluge) with gray reference areas transparent.
|
||||
*/
|
||||
public final class TvCalibrationGenerator {
|
||||
private static final int[] BAR_COLORS = {
|
||||
Color.WHITE, Color.YELLOW, Color.CYAN, Color.GREEN,
|
||||
Color.MAGENTA, Color.RED, Color.BLUE
|
||||
};
|
||||
|
||||
private TvCalibrationGenerator() {}
|
||||
|
||||
public static void drawChart(Canvas canvas, int width, int height, Context context) {
|
||||
if (context == null) {
|
||||
drawProceduralChart(canvas, width, height);
|
||||
return;
|
||||
}
|
||||
canvas.drawColor(Color.BLACK);
|
||||
Bitmap ref = loadReference(context);
|
||||
if (ref != null) {
|
||||
drawReferenceWithTransparentGray(canvas, ref, width, height);
|
||||
ref.recycle();
|
||||
return;
|
||||
}
|
||||
drawProceduralChart(canvas, width, height);
|
||||
}
|
||||
|
||||
private static Bitmap loadReference(Context context) {
|
||||
try {
|
||||
return BitmapFactory.decodeResource(context.getResources(), R.drawable.calibration_chart_reference);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void drawReferenceWithTransparentGray(Canvas canvas, Bitmap ref, int w, int h) {
|
||||
Bitmap scaled = Bitmap.createScaledBitmap(ref, w, h, true);
|
||||
Bitmap keyed = scaled.copy(Bitmap.Config.ARGB_8888, true);
|
||||
scaled.recycle();
|
||||
int[] pixels = new int[w * h];
|
||||
keyed.getPixels(pixels, 0, w, 0, 0, w, h);
|
||||
for (int i = 0; i < pixels.length; i++) {
|
||||
int p = pixels[i];
|
||||
int r = (p >> 16) & 0xff;
|
||||
int g = (p >> 8) & 0xff;
|
||||
int b = p & 0xff;
|
||||
if (isGray(r, g, b)) {
|
||||
pixels[i] = 0;
|
||||
}
|
||||
}
|
||||
keyed.setPixels(pixels, 0, w, 0, 0, w, h);
|
||||
canvas.drawBitmap(keyed, 0, 0, null);
|
||||
keyed.recycle();
|
||||
}
|
||||
|
||||
private static boolean isGray(int r, int g, int b) {
|
||||
return Math.abs(r - g) < 25 && Math.abs(g - b) < 25 && r > 40 && r < 200;
|
||||
}
|
||||
|
||||
private static void drawProceduralChart(Canvas canvas, int width, int height) {
|
||||
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
int barH = (int) (height * 0.55f);
|
||||
int barW = width / BAR_COLORS.length;
|
||||
for (int i = 0; i < BAR_COLORS.length; i++) {
|
||||
paint.setColor(BAR_COLORS[i]);
|
||||
canvas.drawRect(i * barW, 0, (i + 1) * barW, barH, paint);
|
||||
}
|
||||
int lowerTop = barH;
|
||||
int cols = 8;
|
||||
int cellW = width / cols;
|
||||
for (int c = 0; c < cols; c++) {
|
||||
int level = 255 - (c * 255 / (cols - 1));
|
||||
if (c == 0 || c == cols - 1) {
|
||||
continue;
|
||||
}
|
||||
paint.setColor(Color.rgb(level, level, level));
|
||||
canvas.drawRect(c * cellW, lowerTop, (c + 1) * cellW, height, paint);
|
||||
}
|
||||
paint.setColor(Color.WHITE);
|
||||
canvas.drawRect(0, lowerTop, cellW, height, paint);
|
||||
paint.setColor(Color.BLACK);
|
||||
canvas.drawRect((cols - 1) * cellW, lowerTop, width, height, paint);
|
||||
}
|
||||
|
||||
/** Blue/white vertical stripe border with optional VU-driven stripe width (8dp border). */
|
||||
public static void drawAnimatedBorder(Canvas canvas, int width, int height, float density,
|
||||
int scrollPx, float vuLevel, Context context) {
|
||||
drawChart(canvas, width, height, context);
|
||||
drawBorderOverlay(canvas, width, height, density, scrollPx, vuLevel);
|
||||
}
|
||||
|
||||
/** Draws only the animated border (chart must already be on the canvas / bitmap). */
|
||||
public static void drawBorderOverlay(Canvas canvas, int width, int height, float density,
|
||||
int scrollPx, float vuLevel) {
|
||||
int borderPx = Math.max(2, Math.round(8f * density));
|
||||
int stripe = Math.max(1, Math.round(1f * density));
|
||||
int maxScroll = width + height;
|
||||
int offset = scrollPx % Math.max(1, maxScroll);
|
||||
float vu = Math.max(0f, Math.min(1f, vuLevel));
|
||||
int blueWidth = Math.max(stripe, Math.round(stripe + vu * borderPx * 1.35f));
|
||||
drawStripeFrame(canvas, width, height, borderPx, stripe, blueWidth, offset);
|
||||
}
|
||||
|
||||
private static void drawStripeFrame(Canvas canvas, int width, int height, int border,
|
||||
int stripe, int blueWidth, int offset) {
|
||||
Paint paint = new Paint();
|
||||
int cycle = stripe + blueWidth;
|
||||
RectF top = new RectF(0, 0, width, border);
|
||||
RectF bottom = new RectF(0, height - border, width, height);
|
||||
RectF left = new RectF(0, 0, border, height);
|
||||
RectF right = new RectF(width - border, 0, width, height);
|
||||
fillStripes(canvas, paint, top, true, offset, cycle, blueWidth);
|
||||
fillStripes(canvas, paint, bottom, true, offset, cycle, blueWidth);
|
||||
fillStripes(canvas, paint, left, false, offset, cycle, blueWidth);
|
||||
fillStripes(canvas, paint, right, false, offset, cycle, blueWidth);
|
||||
}
|
||||
|
||||
private static void fillStripes(Canvas canvas, Paint paint, RectF area, boolean horizontal,
|
||||
int offset, int cycle, int blueWidth) {
|
||||
if (horizontal) {
|
||||
int h = Math.max(1, Math.round(area.height()));
|
||||
for (float x = area.left; x < area.right; ) {
|
||||
int pos = (int) (x + area.top + offset);
|
||||
int color = stripeColor(pos, cycle, blueWidth);
|
||||
float next = Math.min(area.right, x + 1f);
|
||||
paint.setColor(color);
|
||||
canvas.drawRect(x, area.top, next, area.top + h, paint);
|
||||
x = next;
|
||||
}
|
||||
} else {
|
||||
int w = Math.max(1, Math.round(area.width()));
|
||||
for (float y = area.top; y < area.bottom; ) {
|
||||
int pos = (int) (area.left + y + offset);
|
||||
int color = stripeColor(pos, cycle, blueWidth);
|
||||
float next = Math.min(area.bottom, y + 1f);
|
||||
paint.setColor(color);
|
||||
canvas.drawRect(area.left, y, area.left + w, next, paint);
|
||||
y = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int stripeColor(int pos, int cycle, int blueWidth) {
|
||||
int m = pos % cycle;
|
||||
return m < blueWidth ? Color.rgb(30, 144, 255) : Color.WHITE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.foxx.androidcast.sender.camera;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.SurfaceTexture;
|
||||
import android.hardware.camera2.CameraAccessException;
|
||||
import android.hardware.camera2.CameraCaptureSession;
|
||||
import android.hardware.camera2.CameraCharacteristics;
|
||||
import android.hardware.camera2.CameraDevice;
|
||||
import android.hardware.camera2.CameraManager;
|
||||
import android.hardware.camera2.CaptureRequest;
|
||||
import android.hardware.camera2.params.StreamConfigurationMap;
|
||||
import android.os.Handler;
|
||||
import android.os.HandlerThread;
|
||||
import android.util.Log;
|
||||
import android.util.Size;
|
||||
import android.view.Surface;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
/** Camera2 preview into the video encoder input {@link Surface}. */
|
||||
public final class CameraCastCapture {
|
||||
private static final String TAG = "CameraCastCapture";
|
||||
|
||||
private final Context appContext;
|
||||
private HandlerThread thread;
|
||||
private Handler handler;
|
||||
private CameraDevice camera;
|
||||
private CameraCaptureSession session;
|
||||
private String cameraId;
|
||||
|
||||
public CameraCastCapture(Context context) {
|
||||
appContext = context.getApplicationContext();
|
||||
}
|
||||
|
||||
public void start(Surface encoderSurface, int targetWidth, int targetHeight) throws CameraAccessException {
|
||||
stop();
|
||||
thread = new HandlerThread("CameraCast");
|
||||
thread.start();
|
||||
handler = new Handler(thread.getLooper());
|
||||
CameraManager mgr = appContext.getSystemService(CameraManager.class);
|
||||
cameraId = chooseCameraId(mgr);
|
||||
if (cameraId == null) {
|
||||
throw new CameraAccessException(CameraAccessException.CAMERA_ERROR,
|
||||
"No back camera available");
|
||||
}
|
||||
Size preview = choosePreviewSize(mgr, cameraId, targetWidth, targetHeight);
|
||||
Log.i(TAG, "Opening camera " + cameraId + " preview " + preview.getWidth() + "x" + preview.getHeight());
|
||||
mgr.openCamera(cameraId, new CameraDevice.StateCallback() {
|
||||
@Override
|
||||
public void onOpened(CameraDevice device) {
|
||||
camera = device;
|
||||
try {
|
||||
createSession(device, encoderSurface, preview);
|
||||
} catch (CameraAccessException e) {
|
||||
Log.e(TAG, "Session failed", e);
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnected(CameraDevice device) {
|
||||
device.close();
|
||||
camera = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(CameraDevice device, int error) {
|
||||
Log.e(TAG, "Camera error " + error);
|
||||
device.close();
|
||||
camera = null;
|
||||
}
|
||||
}, handler);
|
||||
}
|
||||
|
||||
private void createSession(CameraDevice device, Surface encoderSurface, Size preview)
|
||||
throws CameraAccessException {
|
||||
CaptureRequest.Builder builder = device.createCaptureRequest(CameraDevice.TEMPLATE_RECORD);
|
||||
builder.addTarget(encoderSurface);
|
||||
device.createCaptureSession(Collections.singletonList(encoderSurface),
|
||||
new CameraCaptureSession.StateCallback() {
|
||||
@Override
|
||||
public void onConfigured(CameraCaptureSession captureSession) {
|
||||
session = captureSession;
|
||||
try {
|
||||
builder.set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO);
|
||||
captureSession.setRepeatingRequest(builder.build(), null, handler);
|
||||
Log.i(TAG, "Camera preview running");
|
||||
} catch (CameraAccessException e) {
|
||||
Log.e(TAG, "Repeating request failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigureFailed(CameraCaptureSession captureSession) {
|
||||
Log.e(TAG, "Camera session configure failed");
|
||||
}
|
||||
}, handler);
|
||||
}
|
||||
|
||||
private static String chooseCameraId(CameraManager mgr) throws CameraAccessException {
|
||||
for (String id : mgr.getCameraIdList()) {
|
||||
CameraCharacteristics chars = mgr.getCameraCharacteristics(id);
|
||||
Integer facing = chars.get(CameraCharacteristics.LENS_FACING);
|
||||
if (facing != null && facing == CameraCharacteristics.LENS_FACING_BACK) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
String[] ids = mgr.getCameraIdList();
|
||||
return ids.length > 0 ? ids[0] : null;
|
||||
}
|
||||
|
||||
private static Size choosePreviewSize(CameraManager mgr, String id, int targetW, int targetH)
|
||||
throws CameraAccessException {
|
||||
CameraCharacteristics chars = mgr.getCameraCharacteristics(id);
|
||||
StreamConfigurationMap map = chars.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);
|
||||
if (map == null) {
|
||||
return new Size(targetW, targetH);
|
||||
}
|
||||
Size[] sizes = map.getOutputSizes(SurfaceTexture.class);
|
||||
if (sizes == null || sizes.length == 0) {
|
||||
return new Size(targetW, targetH);
|
||||
}
|
||||
Size best = sizes[0];
|
||||
long bestScore = Long.MAX_VALUE;
|
||||
for (Size s : sizes) {
|
||||
long score = Math.abs((long) s.getWidth() * s.getHeight() - (long) targetW * targetH);
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
best = s;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
if (session != null) {
|
||||
try {
|
||||
session.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
session = null;
|
||||
}
|
||||
if (camera != null) {
|
||||
try {
|
||||
camera.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
camera = null;
|
||||
}
|
||||
if (thread != null) {
|
||||
thread.quitSafely();
|
||||
thread = null;
|
||||
}
|
||||
handler = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package com.foxx.androidcast.stats;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
/**
|
||||
* Collects anonymous per-session aggregates; persisted via {@link SessionStatsStore}.
|
||||
*/
|
||||
public final class SessionStatsRecorder {
|
||||
private final long startedMs = System.currentTimeMillis();
|
||||
private final String role;
|
||||
private final String transport;
|
||||
private String videoCodec = "";
|
||||
private String audioCodec = "AAC";
|
||||
private String captureMode = "";
|
||||
private String resolution = "";
|
||||
private int clientCount = 1;
|
||||
private int disruptCount;
|
||||
private int idleTimeouts;
|
||||
private int networkSamples;
|
||||
|
||||
private long sumRttMs;
|
||||
private long sumLossRatioMicro; // loss * 1e6
|
||||
private long sumVideoBitrateKbps;
|
||||
private long sumAudioBitrateKbps;
|
||||
private long sumEncodeFpsMicro;
|
||||
private long sumDecodeFpsMicro;
|
||||
private int bitrateSamples;
|
||||
private int fpsSamples;
|
||||
|
||||
private final TransportLossStats lossTotals = new TransportLossStats();
|
||||
|
||||
public SessionStatsRecorder(String role, String transport) {
|
||||
this.role = role;
|
||||
this.transport = transport != null ? transport : "unknown";
|
||||
}
|
||||
|
||||
public void setClientCount(int count) {
|
||||
if (count > 0) {
|
||||
clientCount = count;
|
||||
}
|
||||
}
|
||||
|
||||
public void setCodecs(String videoMime, String audioMime) {
|
||||
if (videoMime != null) {
|
||||
videoCodec = videoMime;
|
||||
}
|
||||
if (audioMime != null) {
|
||||
audioCodec = audioMime;
|
||||
}
|
||||
}
|
||||
|
||||
public void setCaptureMode(String mode) {
|
||||
captureMode = mode != null ? mode : "";
|
||||
}
|
||||
|
||||
public void setResolution(int w, int h) {
|
||||
if (w > 0 && h > 0) {
|
||||
resolution = w + "x" + h;
|
||||
}
|
||||
}
|
||||
|
||||
public void noteDisrupt(String reason) {
|
||||
disruptCount++;
|
||||
}
|
||||
|
||||
public void noteIdleTimeout() {
|
||||
idleTimeouts++;
|
||||
disruptCount++;
|
||||
}
|
||||
|
||||
public void sampleNetwork(NetworkStatsSnapshot local, NetworkStatsSnapshot peer) {
|
||||
networkSamples++;
|
||||
NetworkStatsSnapshot s = local != null ? local : peer;
|
||||
if (s == null) {
|
||||
return;
|
||||
}
|
||||
if (s.rttMs > 0) {
|
||||
sumRttMs += s.rttMs;
|
||||
}
|
||||
if (s.lossRatio > 0) {
|
||||
sumLossRatioMicro += (long) (s.lossRatio * 1_000_000);
|
||||
}
|
||||
if (s.sendBitrateKbps > 0) {
|
||||
sumVideoBitrateKbps += s.sendBitrateKbps;
|
||||
bitrateSamples++;
|
||||
} else if (s.recvBitrateKbps > 0) {
|
||||
sumVideoBitrateKbps += s.recvBitrateKbps;
|
||||
bitrateSamples++;
|
||||
}
|
||||
if (s.encodeFps > 0) {
|
||||
sumEncodeFpsMicro += (long) (s.encodeFps * 1000);
|
||||
fpsSamples++;
|
||||
}
|
||||
if (s.targetBitrateKbps > 0) {
|
||||
sumAudioBitrateKbps += Math.min(s.targetBitrateKbps, 256);
|
||||
}
|
||||
}
|
||||
|
||||
/** Final transport/app loss snapshot for this session (not accumulated over time). */
|
||||
public void mergeLoss(TransportLossStats loss) {
|
||||
if (loss == null) {
|
||||
return;
|
||||
}
|
||||
synchronized (lossTotals) {
|
||||
lossTotals.datagramsSent = loss.datagramsSent;
|
||||
lossTotals.datagramsReceived = loss.datagramsReceived;
|
||||
lossTotals.datagramsInvalid = loss.datagramsInvalid;
|
||||
lossTotals.fragmentsReceived = loss.fragmentsReceived;
|
||||
lossTotals.fragmentsDuplicate = loss.fragmentsDuplicate;
|
||||
lossTotals.reassemblyCompleted = loss.reassemblyCompleted;
|
||||
lossTotals.reassemblyIncomplete = loss.reassemblyIncomplete;
|
||||
lossTotals.reassemblySuperseded = loss.reassemblySuperseded;
|
||||
lossTotals.wirePacketsDropped = loss.wirePacketsDropped;
|
||||
lossTotals.malformedPackets = loss.malformedPackets;
|
||||
lossTotals.sendDroppedVideoP = loss.sendDroppedVideoP;
|
||||
lossTotals.sendDroppedAudio = loss.sendDroppedAudio;
|
||||
lossTotals.recvVideoSkippedAwaitingKey = loss.recvVideoSkippedAwaitingKey;
|
||||
lossTotals.recvVideoDecodeErrors = loss.recvVideoDecodeErrors;
|
||||
lossTotals.recvAudioDecodeErrors = loss.recvAudioDecodeErrors;
|
||||
lossTotals.recvAudioQueueDrops = loss.recvAudioQueueDrops;
|
||||
lossTotals.protectionMode = loss.protectionMode;
|
||||
}
|
||||
}
|
||||
|
||||
public void mergeSettings(CastSettings settings) {
|
||||
if (settings == null) {
|
||||
return;
|
||||
}
|
||||
captureMode = settings.getCaptureMode() != null ? settings.getCaptureMode().name() : captureMode;
|
||||
if (settings.getNegotiatedVideoMime() != null) {
|
||||
videoCodec = settings.getNegotiatedVideoMime();
|
||||
}
|
||||
}
|
||||
|
||||
public void finish(Context context) {
|
||||
long endedMs = System.currentTimeMillis();
|
||||
try {
|
||||
JSONObject root = new JSONObject();
|
||||
root.put("schema_version", 1);
|
||||
root.put("started_at_epoch_ms", startedMs);
|
||||
root.put("ended_at_epoch_ms", endedMs);
|
||||
root.put("duration_ms", endedMs - startedMs);
|
||||
root.put("role", role);
|
||||
root.put("transport", transport);
|
||||
root.put("client_count", clientCount);
|
||||
root.put("disrupt_count", disruptCount);
|
||||
root.put("idle_timeout_count", idleTimeouts);
|
||||
root.put("video_codec", videoCodec);
|
||||
root.put("audio_codec", audioCodec);
|
||||
root.put("capture_mode", captureMode);
|
||||
root.put("resolution", resolution);
|
||||
root.put("network_samples", networkSamples);
|
||||
|
||||
if (networkSamples > 0) {
|
||||
root.put("avg_rtt_ms", sumRttMs / Math.max(1, networkSamples));
|
||||
root.put("avg_loss_ratio", sumLossRatioMicro / (1_000_000.0 * Math.max(1, networkSamples)));
|
||||
}
|
||||
if (bitrateSamples > 0) {
|
||||
root.put("avg_video_bitrate_kbps", sumVideoBitrateKbps / bitrateSamples);
|
||||
}
|
||||
if (fpsSamples > 0) {
|
||||
root.put("avg_encode_fps", sumEncodeFpsMicro / (1000.0 * fpsSamples));
|
||||
}
|
||||
|
||||
JSONObject protection = new JSONObject();
|
||||
protection.put("mode", lossTotals.protectionMode.name());
|
||||
protection.put("fec", "stub_disabled");
|
||||
protection.put("nack", "stub_disabled");
|
||||
protection.put("fec_stub_packets", lossTotals.fecPacketsStub);
|
||||
protection.put("nack_stub_requests", lossTotals.nackRequestsStub);
|
||||
root.put("stream_protection", protection);
|
||||
|
||||
JSONObject udp = new JSONObject();
|
||||
synchronized (lossTotals) {
|
||||
udp.put("datagrams_tx", lossTotals.datagramsSent);
|
||||
udp.put("datagrams_rx", lossTotals.datagramsReceived);
|
||||
udp.put("datagrams_invalid", lossTotals.datagramsInvalid);
|
||||
udp.put("fragments_rx", lossTotals.fragmentsReceived);
|
||||
udp.put("fragments_duplicate", lossTotals.fragmentsDuplicate);
|
||||
udp.put("reassembly_ok", lossTotals.reassemblyCompleted);
|
||||
udp.put("reassembly_lost", lossTotals.reassemblyIncomplete + lossTotals.reassemblySuperseded);
|
||||
udp.put("wire_dropped", lossTotals.wirePacketsDropped);
|
||||
udp.put("malformed", lossTotals.malformedPackets);
|
||||
udp.put("send_dropped_video_p", lossTotals.sendDroppedVideoP);
|
||||
udp.put("send_dropped_audio", lossTotals.sendDroppedAudio);
|
||||
udp.put("recv_video_skipped_awaiting_key", lossTotals.recvVideoSkippedAwaitingKey);
|
||||
udp.put("recv_video_decode_errors", lossTotals.recvVideoDecodeErrors);
|
||||
udp.put("recv_audio_decode_errors", lossTotals.recvAudioDecodeErrors);
|
||||
udp.put("recv_audio_queue_drops", lossTotals.recvAudioQueueDrops);
|
||||
udp.put("recv_damaged_video_frames", lossTotals.recvDamagedVideoFrames);
|
||||
}
|
||||
root.put("udp", udp);
|
||||
|
||||
SessionStatsStore.saveSession(context, startedMs, root);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.foxx.androidcast.stats;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Rotating flat JSON session logs in app-private storage. */
|
||||
public final class SessionStatsStore {
|
||||
private static final String DIR_NAME = "session_stats";
|
||||
private static final String PREFIX = "session_";
|
||||
private static final String SUFFIX = ".json";
|
||||
|
||||
private SessionStatsStore() {}
|
||||
|
||||
public static File statsDir(Context context) {
|
||||
return new File(context.getApplicationContext().getFilesDir(), DIR_NAME);
|
||||
}
|
||||
|
||||
/** Keep at most {@link CastConfig#SESSION_STATS_MAX_FILES} - 1 files before a new session. */
|
||||
public static void pruneOnAppStart(Context context) {
|
||||
pruneDirectory(statsDir(context), CastConfig.SESSION_STATS_MAX_FILES);
|
||||
}
|
||||
|
||||
/** Deletes oldest session JSON files until count is below {@code maxFiles}. */
|
||||
static void pruneDirectory(File dir, int maxFiles) {
|
||||
if (dir == null || !dir.exists()) {
|
||||
return;
|
||||
}
|
||||
File[] files = dir.listFiles((d, name) -> name.startsWith(PREFIX) && name.endsWith(SUFFIX));
|
||||
if (files == null || files.length == 0) {
|
||||
return;
|
||||
}
|
||||
List<File> list = new ArrayList<>();
|
||||
Collections.addAll(list, files);
|
||||
Collections.sort(list, Comparator.comparing(File::getName));
|
||||
while (list.size() >= maxFiles) {
|
||||
File oldest = list.remove(0);
|
||||
if (!oldest.delete()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void saveSession(Context context, long startedMs, JSONObject json) throws Exception {
|
||||
File dir = statsDir(context);
|
||||
if (!dir.exists() && !dir.mkdirs()) {
|
||||
throw new IllegalStateException("Cannot create session_stats dir");
|
||||
}
|
||||
String name = PREFIX + formatTimestamp(startedMs) + SUFFIX;
|
||||
File out = new File(dir, name);
|
||||
byte[] bytes = json.toString(2).getBytes(StandardCharsets.UTF_8);
|
||||
try (FileOutputStream fos = new FileOutputStream(out)) {
|
||||
fos.write(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
private static String formatTimestamp(long epochMs) {
|
||||
return new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date(epochMs));
|
||||
}
|
||||
}
|
||||
@@ -29,12 +29,19 @@ public class AspectRatioFrameLayout extends FrameLayout {
|
||||
|
||||
@Override
|
||||
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
||||
if (aspectRatio <= 0f || getChildCount() == 0) {
|
||||
return;
|
||||
}
|
||||
int parentW = MeasureSpec.getSize(widthMeasureSpec);
|
||||
int parentH = MeasureSpec.getSize(heightMeasureSpec);
|
||||
if (getChildCount() == 0) {
|
||||
setMeasuredDimension(parentW, parentH);
|
||||
return;
|
||||
}
|
||||
if (aspectRatio <= 0f) {
|
||||
int fullW = MeasureSpec.makeMeasureSpec(parentW, MeasureSpec.EXACTLY);
|
||||
int fullH = MeasureSpec.makeMeasureSpec(parentH, MeasureSpec.EXACTLY);
|
||||
measureChild(getChildAt(0), fullW, fullH);
|
||||
setMeasuredDimension(parentW, parentH);
|
||||
return;
|
||||
}
|
||||
int childW;
|
||||
int childH;
|
||||
float parentRatio = (float) parentW / parentH;
|
||||
@@ -56,6 +63,10 @@ public class AspectRatioFrameLayout extends FrameLayout {
|
||||
if (getChildCount() == 0) {
|
||||
return;
|
||||
}
|
||||
if (aspectRatio <= 0f) {
|
||||
getChildAt(0).layout(left, top, right, bottom);
|
||||
return;
|
||||
}
|
||||
int parentW = right - left;
|
||||
int parentH = bottom - top;
|
||||
int childW = getChildAt(0).getMeasuredWidth();
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.foxx.androidcast.ui;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.view.TextureView;
|
||||
|
||||
/**
|
||||
* Letterboxes decoded video in a {@link TextureView}.
|
||||
* Uses view-space scale around center (same approach as ExoPlayer), not pixel {@link Matrix#setRectToRect}.
|
||||
*/
|
||||
public final class PlaybackTextureTransform {
|
||||
private PlaybackTextureTransform() {}
|
||||
|
||||
public static void applyFitCenter(TextureView view, int videoWidth, int videoHeight) {
|
||||
if (view == null || videoWidth <= 0 || videoHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
int viewW = view.getWidth();
|
||||
int viewH = view.getHeight();
|
||||
if (viewW <= 0 || viewH <= 0) {
|
||||
return;
|
||||
}
|
||||
float videoAspect = (float) videoWidth / videoHeight;
|
||||
float viewAspect = (float) viewW / viewH;
|
||||
float scaleX = 1f;
|
||||
float scaleY = 1f;
|
||||
if (videoAspect > viewAspect) {
|
||||
scaleY = viewAspect / videoAspect;
|
||||
} else {
|
||||
scaleX = videoAspect / viewAspect;
|
||||
}
|
||||
Matrix matrix = new Matrix();
|
||||
matrix.setScale(scaleX, scaleY, viewW / 2f, viewH / 2f);
|
||||
view.setTransform(matrix);
|
||||
}
|
||||
|
||||
public static void clearTransform(TextureView view) {
|
||||
if (view != null) {
|
||||
view.setTransform(new Matrix());
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
app/src/main/res/drawable-nodpi/calibration_chart_reference.png
Normal file
BIN
app/src/main/res/drawable-nodpi/calibration_chart_reference.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
@@ -16,7 +16,7 @@
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center">
|
||||
|
||||
<SurfaceView
|
||||
<TextureView
|
||||
android:id="@+id/surface_playback"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
<string name="app_name">Android Cast</string>
|
||||
<string name="send_screen">Send screen (tablet / phone)</string>
|
||||
<string name="receive_screen">Receive on TV / projector</string>
|
||||
<string name="main_hint">Same Wi‑Fi required. Default PIN: 1234. Match transport (TCP/UDP) on both devices.</string>
|
||||
<string name="main_hint">Same Wi‑Fi required. Default PIN: 1234. UDP is default; pick TCP in settings if needed. Match transport on both devices.</string>
|
||||
<string name="sender_title">Cast to receiver</string>
|
||||
<string name="receiver_title">Waiting for cast</string>
|
||||
<string name="pin_hint">PIN</string>
|
||||
<string name="refresh">Refresh</string>
|
||||
<string name="refresh">REFRESH</string>
|
||||
<string name="refresh_countdown">REFRESH (%1$d)</string>
|
||||
<string name="start_cast">Start cast</string>
|
||||
<string name="searching">Searching for receivers…</string>
|
||||
<string name="waiting_sender">Waiting for sender…</string>
|
||||
@@ -25,8 +26,16 @@
|
||||
<string name="audio_unavailable_title">Video only</string>
|
||||
<string name="audio_unavailable_message">This device runs Android 9 or lower. Playback audio capture needs Android 10 or newer. You can continue with video only.</string>
|
||||
<string name="continue_video_only">Continue (video only)</string>
|
||||
<string name="transport_tcp">TCP (reliable)</string>
|
||||
<string name="transport_udp">UDP (best effort)</string>
|
||||
<string name="transport_udp">UDP (default, best effort)</string>
|
||||
<string name="transport_tcp">TCP (reliable fallback)</string>
|
||||
<string name="transport_quic">QUIC experimental (Cronet)</string>
|
||||
<string name="transport_webrtc">WebRTC (stub, future)</string>
|
||||
<string name="capture_calibration_test">Calibration test pattern</string>
|
||||
<string name="capture_camera">Camera (rear Camera2)</string>
|
||||
<string name="capture_camera_unavailable">Camera source (requires Android 10+)</string>
|
||||
<string name="capture_camera_not_implemented">Camera capture is not implemented yet. Use screen or calibration test.</string>
|
||||
<string name="casting_calibration">Calibration cast %1$dx%2$d</string>
|
||||
<string name="casting_camera">Camera cast %1$dx%2$d</string>
|
||||
<string name="quality_low">Low (~1.5 Mbps, 24 fps)</string>
|
||||
<string name="quality_medium">Medium (~5 Mbps, 30 fps) — recommended</string>
|
||||
<string name="quality_high">High (~10 Mbps, 30 fps)</string>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.foxx.androidcast;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
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);
|
||||
assertTrue(size.width > size.height);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.foxx.androidcast.media;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.media.MediaFormat;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class EncodedStreamProbeTest {
|
||||
@Test
|
||||
public void hevcKeyframeReportsNalType() {
|
||||
byte[] vps = {0, 0, 0, 1, 0x40, 0x01, 0x0c, 0x01};
|
||||
String desc = EncodedStreamProbe.describeKeyframe(MediaFormat.MIMETYPE_VIDEO_HEVC, vps);
|
||||
assertTrue(desc.contains("HEVC"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.foxx.androidcast.media;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class H264BitstreamHevcTest {
|
||||
@Test
|
||||
public void avccHevcNalGetsStartCode() {
|
||||
byte[] vps = {0, 0, 0, 4, 0x40, 0x01, 0x0c, 0x01};
|
||||
byte[] annexB = H264Bitstream.toAnnexB(vps);
|
||||
assertTrue(annexB[0] == 0 && annexB[1] == 0 && annexB[2] == 0 && annexB[3] == 1);
|
||||
assertEquals(0x40, annexB[4] & 0xff);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.foxx.androidcast.media;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class RgbToNv21Test {
|
||||
|
||||
@Test
|
||||
public void whitePixelProducesHighLuma() {
|
||||
int[] argb = {0xFFFFFFFF, 0, 0, 0xFFFFFFFF};
|
||||
byte[] nv21 = RgbToNv21.fromArgb(argb, 2, 2);
|
||||
int y = nv21[0] & 0xff;
|
||||
assertTrue(y > 220);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void blackPixelProducesLowLuma() {
|
||||
int[] argb = {0xFF000000, 0, 0, 0xFF000000};
|
||||
byte[] nv21 = RgbToNv21.fromArgb(argb, 2, 2);
|
||||
int y = nv21[0] & 0xff;
|
||||
assertEquals(16, y);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bufferSizeMatchesNv21Layout() {
|
||||
int w = 16;
|
||||
int h = 16;
|
||||
int[] argb = new int[w * h];
|
||||
byte[] nv21 = RgbToNv21.fromArgb(argb, w, h);
|
||||
assertEquals(w * h + w * h / 2, nv21.length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.foxx.androidcast.media;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class RgbToYuv420Test {
|
||||
|
||||
@Test
|
||||
public void redPixelDiffersBetweenYAndChromaPlanes() {
|
||||
int[] argb = {0xFFFF0000, 0, 0, 0xFFFF0000};
|
||||
byte[] i420 = RgbToYuv420.fromArgb(argb, 2, 2, RgbToYuv420.Layout.I420);
|
||||
int y = i420[0] & 0xff;
|
||||
int u = i420[4] & 0xff;
|
||||
int v = i420[5] & 0xff;
|
||||
assertNotEquals(y, u);
|
||||
assertNotEquals(y, v);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bufferSizeMatchesI420Layout() {
|
||||
assertEquals(960 * 540 + (960 * 540) / 2, RgbToYuv420.bufferSize(960, 540));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.foxx.androidcast.network;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class CastProtoHeaderTest {
|
||||
|
||||
@Test
|
||||
public void encodeDecodeRoundTrip() throws Exception {
|
||||
CastProtoHeader original = CastProtoHeader.forCast(0x12345678, 0xABCDEF01, 0x0F, 0xF0);
|
||||
byte[] wire = original.encode();
|
||||
CastProtoHeader decoded = CastProtoHeader.decode(wire, 0, wire.length);
|
||||
assertEquals(original.sessionId, decoded.sessionId);
|
||||
assertEquals(original.placeholderId, decoded.placeholderId);
|
||||
assertEquals(original.videoCodecs, decoded.videoCodecs);
|
||||
assertEquals(original.audioCodecs, decoded.audioCodecs);
|
||||
assertTrue(decoded.verifyChecksum());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void frameUnframePreservesPayload() throws Exception {
|
||||
CastProtoHeader hdr = CastProtoHeader.forCast(99, 1, 2, 3);
|
||||
byte[] payload = new byte[] {1, 2, 3, 4};
|
||||
byte[] wire = CastPacketFramer.frame(hdr, (byte) 7, payload);
|
||||
CastProtoHeader fromWire = CastPacketFramer.readHeader(wire);
|
||||
assertEquals(99, fromWire.sessionId);
|
||||
CastProtocol.Message msg = CastPacketFramer.unframe(wire);
|
||||
assertEquals(7, msg.type);
|
||||
assertArrayEquals(payload, msg.payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.foxx.androidcast.network;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class CastSessionGateTest {
|
||||
|
||||
@Test
|
||||
public void bindsFirstNonZeroSession() {
|
||||
CastSessionGate gate = new CastSessionGate();
|
||||
CastProtoHeader first = new CastProtoHeader();
|
||||
first.sessionId = 42;
|
||||
assertTrue(gate.accept(first));
|
||||
assertEquals(42, gate.getBoundSessionId());
|
||||
|
||||
CastProtoHeader stale = new CastProtoHeader();
|
||||
stale.sessionId = 99;
|
||||
assertFalse(gate.accept(stale));
|
||||
|
||||
CastProtoHeader same = new CastProtoHeader();
|
||||
same.sessionId = 42;
|
||||
assertTrue(gate.accept(same));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void zeroSessionAlwaysAccepted() {
|
||||
CastSessionGate gate = new CastSessionGate();
|
||||
CastProtoHeader zero = new CastProtoHeader();
|
||||
zero.sessionId = 0;
|
||||
assertTrue(gate.accept(zero));
|
||||
assertFalse(gate.isBound());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resetAllowsRebind() {
|
||||
CastSessionGate gate = new CastSessionGate();
|
||||
CastProtoHeader a = new CastProtoHeader();
|
||||
a.sessionId = 1;
|
||||
gate.accept(a);
|
||||
gate.reset();
|
||||
CastProtoHeader b = new CastProtoHeader();
|
||||
b.sessionId = 2;
|
||||
assertTrue(gate.accept(b));
|
||||
assertEquals(2, gate.getBoundSessionId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.foxx.androidcast.stats;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Arrays;
|
||||
|
||||
public class SessionStatsStoreTest {
|
||||
|
||||
@Test
|
||||
public void pruneDirectory_keepsAtMostMaxMinusOne() throws IOException {
|
||||
File dir = Files.createTempDirectory("session_stats_test").toFile();
|
||||
int max = CastConfig.SESSION_STATS_MAX_FILES;
|
||||
for (int i = 0; i < max + 5; i++) {
|
||||
File f = new File(dir, String.format("session_20260101_%06d.json", i));
|
||||
assertTrue(f.createNewFile());
|
||||
Files.write(f.toPath(), "{}".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
assertEquals(max + 5, dir.list((d, n) -> n.endsWith(".json")).length);
|
||||
|
||||
SessionStatsStore.pruneDirectory(dir, max);
|
||||
|
||||
String[] remaining = dir.list((d, n) -> n.endsWith(".json"));
|
||||
Arrays.sort(remaining);
|
||||
assertEquals(max - 1, remaining.length);
|
||||
assertEquals("session_20260101_000006.json", remaining[0]);
|
||||
assertEquals("session_20260101_000024.json", remaining[remaining.length - 1]);
|
||||
|
||||
for (File f : dir.listFiles()) {
|
||||
assertTrue(f.delete());
|
||||
}
|
||||
assertTrue(dir.delete());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.foxx.androidcast.ui;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class PlaybackTextureTransformTest {
|
||||
@Test
|
||||
public void wideVideoOnWideViewIsUnityScale() {
|
||||
float[] scales = scalesFor(1920, 1080, 960, 540);
|
||||
assertEquals(1f, scales[0], 0.01f);
|
||||
assertEquals(1f, scales[1], 0.01f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tallVideoOnWideViewLetterboxesHorizontally() {
|
||||
float[] scales = scalesFor(1920, 1080, 540, 960);
|
||||
assertEquals(0.316f, 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;
|
||||
float scaleX = 1f;
|
||||
float scaleY = 1f;
|
||||
if (videoAspect > viewAspect) {
|
||||
scaleY = viewAspect / videoAspect;
|
||||
} else {
|
||||
scaleX = videoAspect / viewAspect;
|
||||
}
|
||||
return new float[] {scaleX, scaleY};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user