diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ce550b6..0d18ca3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -10,6 +10,8 @@
+
+
+ android:stopWithTask="false"
+ android:foregroundServiceType="mediaProjection|microphone" />
0) {
+ material.append(':');
+ for (byte b : widevine) {
+ material.append(String.format(Locale.US, "%02x", b));
+ }
+ }
+ material.append(':').append(Build.BOARD).append(':').append(Build.DEVICE);
+ return uuidFromSha256(material.toString());
+ }
+
+ private static byte[] readWidevineId() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) {
+ return null;
+ }
+ MediaDrm drm = null;
+ try {
+ drm = new MediaDrm(WIDEVINE_UUID);
+ return drm.getPropertyByteArray(MediaDrm.PROPERTY_DEVICE_UNIQUE_ID);
+ } catch (Exception ignored) {
+ return null;
+ } finally {
+ if (drm != null) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ drm.close();
+ } else {
+ drm.release();
+ }
+ }
+ }
+ }
+
+ private static String uuidFromSha256(String material) {
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] digest = md.digest(material.getBytes(StandardCharsets.UTF_8));
+ long msb = 0;
+ long lsb = 0;
+ for (int i = 0; i < 8; i++) {
+ msb = (msb << 8) | (digest[i] & 0xff);
+ }
+ for (int i = 8; i < 16; i++) {
+ lsb = (lsb << 8) | (digest[i] & 0xff);
+ }
+ return new UUID(msb, lsb).toString();
+ } catch (NoSuchAlgorithmException e) {
+ return UUID.randomUUID().toString();
+ }
+ }
+
+ private static long hashUuid(String uuid) {
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] digest = md.digest(uuid.getBytes(StandardCharsets.UTF_8));
+ long h = 0;
+ for (int i = 0; i < 8; i++) {
+ h = (h << 8) | (digest[i] & 0xff);
+ }
+ return h;
+ } catch (NoSuchAlgorithmException e) {
+ return uuid.hashCode();
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/IntentExtras.java b/app/src/main/java/com/foxx/androidcast/IntentExtras.java
index 36aad5d..768e340 100644
--- a/app/src/main/java/com/foxx/androidcast/IntentExtras.java
+++ b/app/src/main/java/com/foxx/androidcast/IntentExtras.java
@@ -3,6 +3,10 @@ package com.foxx.androidcast;
import android.content.Intent;
import android.os.Build;
+import com.foxx.androidcast.sender.CastReceiverTarget;
+
+import java.util.ArrayList;
+
/** API 33+ safe extras helpers. */
public final class IntentExtras {
private IntentExtras() {}
@@ -26,4 +30,15 @@ public final class IntentExtras {
}
return (CastSettings) intent.getSerializableExtra(CastSettings.EXTRA);
}
+
+ @SuppressWarnings("unchecked")
+ public static ArrayList getReceiverTargets(Intent intent, String key) {
+ if (intent == null) {
+ return null;
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ return intent.getSerializableExtra(key, ArrayList.class);
+ }
+ return (ArrayList) intent.getSerializableExtra(key);
+ }
}
diff --git a/app/src/main/java/com/foxx/androidcast/MainActivity.java b/app/src/main/java/com/foxx/androidcast/MainActivity.java
index c6cd2b0..feb50a3 100644
--- a/app/src/main/java/com/foxx/androidcast/MainActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/MainActivity.java
@@ -24,6 +24,12 @@ public class MainActivity extends AppCompatActivity {
startActivity(new Intent(this, ReceiverActivity.class)));
}
+ @Override
+ protected void onResume() {
+ super.onResume();
+ PermissionHelper.remindIfMissing(this, false);
+ }
+
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
diff --git a/app/src/main/java/com/foxx/androidcast/PermissionHelper.java b/app/src/main/java/com/foxx/androidcast/PermissionHelper.java
index ae47acb..0403b0e 100644
--- a/app/src/main/java/com/foxx/androidcast/PermissionHelper.java
+++ b/app/src/main/java/com/foxx/androidcast/PermissionHelper.java
@@ -2,41 +2,122 @@ package com.foxx.androidcast;
import android.Manifest;
import android.app.Activity;
+import android.content.Intent;
import android.content.pm.PackageManager;
+import android.net.Uri;
import android.os.Build;
+import android.provider.Settings;
+import androidx.appcompat.app.AlertDialog;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import java.util.ArrayList;
import java.util.List;
-/** Requests runtime permissions needed for LAN cast POC. */
+/** Requests and explains runtime permissions for LAN cast. */
public final class PermissionHelper {
public static final int REQUEST_CODE = 1001;
+ public static final class PermissionItem {
+ public final String permission;
+ public final String label;
+ public final boolean required;
+
+ PermissionItem(String permission, String label, boolean required) {
+ this.permission = permission;
+ this.label = label;
+ this.required = required;
+ }
+ }
+
private PermissionHelper() {}
public static boolean hasAll(Activity activity) {
- return missing(activity).isEmpty();
+ return missing(activity, false).isEmpty();
+ }
+
+ public static boolean hasAllForAudio(Activity activity) {
+ return missing(activity, true).isEmpty();
}
public static void request(Activity activity) {
- List missing = missing(activity);
+ request(activity, false);
+ }
+
+ public static void requestForCast(Activity activity, boolean audioEnabled) {
+ request(activity, audioEnabled);
+ }
+
+ /** Shows a dialog listing missing permissions; offers request or app settings. */
+ public static void remindIfMissing(Activity activity, boolean includeAudio) {
+ List missing = describeMissing(activity, includeAudio);
+ if (missing.isEmpty()) {
+ return;
+ }
+ StringBuilder body = new StringBuilder();
+ for (PermissionItem item : missing) {
+ body.append("• ").append(item.label);
+ body.append(item.required ? " (required)\n" : " (recommended)\n");
+ }
+ new AlertDialog.Builder(activity)
+ .setTitle(R.string.permissions_missing_title)
+ .setMessage(body.toString().trim())
+ .setPositiveButton(R.string.permissions_grant, (d, w) ->
+ ActivityCompat.requestPermissions(activity,
+ permissionsOnly(missing), REQUEST_CODE))
+ .setNeutralButton(R.string.permissions_app_settings, (d, w) -> openAppSettings(activity))
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+ }
+
+ private static void request(Activity activity, boolean includeAudio) {
+ List missing = missing(activity, includeAudio);
if (!missing.isEmpty()) {
ActivityCompat.requestPermissions(
activity, missing.toArray(new String[0]), REQUEST_CODE);
}
}
- private static List missing(Activity activity) {
- List needed = new ArrayList<>();
+ private static List missing(Activity activity, boolean includeAudio) {
+ List items = describeMissing(activity, includeAudio);
+ List perms = new ArrayList<>();
+ for (PermissionItem item : items) {
+ perms.add(item.permission);
+ }
+ return perms;
+ }
+
+ private static List describeMissing(Activity activity, boolean includeAudio) {
+ List needed = new ArrayList<>();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
- needed.add(Manifest.permission.POST_NOTIFICATIONS);
+ needed.add(new PermissionItem(Manifest.permission.POST_NOTIFICATIONS,
+ "Notifications (cast status)", true));
+ }
+ }
+ if (includeAudio && PlatformCastSupport.isAudioCaptureSupported()) {
+ if (ContextCompat.checkSelfPermission(activity, Manifest.permission.RECORD_AUDIO)
+ != PackageManager.PERMISSION_GRANTED) {
+ needed.add(new PermissionItem(Manifest.permission.RECORD_AUDIO,
+ "Microphone (fallback when no app audio)", false));
}
}
return needed;
}
+
+ private static String[] permissionsOnly(List items) {
+ String[] arr = new String[items.size()];
+ for (int i = 0; i < items.size(); i++) {
+ arr[i] = items.get(i).permission;
+ }
+ return arr;
+ }
+
+ private static void openAppSettings(Activity activity) {
+ Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
+ intent.setData(Uri.fromParts("package", activity.getPackageName(), null));
+ activity.startActivity(intent);
+ }
}
diff --git a/app/src/main/java/com/foxx/androidcast/PlatformCastSupport.java b/app/src/main/java/com/foxx/androidcast/PlatformCastSupport.java
index 5d50b47..a430f05 100644
--- a/app/src/main/java/com/foxx/androidcast/PlatformCastSupport.java
+++ b/app/src/main/java/com/foxx/androidcast/PlatformCastSupport.java
@@ -14,6 +14,17 @@ public final class PlatformCastSupport {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q;
}
+ /** Per-app / window picker for MediaProjection (Android 14+). */
+ public static boolean isCaptureUserChoiceSupported() {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE;
+ }
+
+ public static CastSettings.CaptureMode defaultCaptureMode() {
+ return isCaptureUserChoiceSupported()
+ ? CastSettings.CaptureMode.USER_CHOICE
+ : CastSettings.CaptureMode.FULL_SCREEN;
+ }
+
/** Applies default audio flag: on for Android 10+, off otherwise. */
public static void applyDefaultAudioSetting(CastSettings settings) {
settings.setAudioEnabled(isAudioCaptureSupported());
diff --git a/app/src/main/java/com/foxx/androidcast/SettingsUi.java b/app/src/main/java/com/foxx/androidcast/SettingsUi.java
index c38b590..04e050c 100644
--- a/app/src/main/java/com/foxx/androidcast/SettingsUi.java
+++ b/app/src/main/java/com/foxx/androidcast/SettingsUi.java
@@ -1,10 +1,12 @@
package com.foxx.androidcast;
+import android.os.Build;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.CheckBox;
import android.widget.Spinner;
+import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
@@ -12,12 +14,31 @@ import com.foxx.androidcast.sender.CodecCatalog;
import java.util.ArrayList;
import java.util.List;
-
/** Binds cast option spinners/checkbox to {@link CastSettings}. */
public final class SettingsUi {
private SettingsUi() {}
+ public static void bindSender(AppCompatActivity activity, CastSettings settings) {
+ bindCommon(activity, settings);
+ bindCaptureModeSender(activity, settings);
+ PlatformCastSupport.bindAudioCheckbox(activity, activity.findViewById(R.id.checkbox_audio), settings);
+ }
+
+ public static void bindReceiver(AppCompatActivity activity, CastSettings settings) {
+ bindCommon(activity, settings);
+ View captureRow = activity.findViewById(R.id.layout_capture_mode);
+ if (captureRow != null) {
+ captureRow.setVisibility(View.GONE);
+ }
+ PlatformCastSupport.bindAudioCheckbox(activity, activity.findViewById(R.id.checkbox_audio), settings);
+ }
+
+ /** @deprecated use {@link #bindSender} or {@link #bindReceiver} */
public static void bind(AppCompatActivity activity, CastSettings settings) {
+ bindSender(activity, settings);
+ }
+
+ private static void bindCommon(AppCompatActivity activity, CastSettings settings) {
PlatformCastSupport.applyDefaultAudioSetting(settings);
Spinner transport = activity.findViewById(R.id.spinner_transport);
@@ -27,7 +48,6 @@ public final class SettingsUi {
Spinner quality = activity.findViewById(R.id.spinner_quality);
Spinner networkAdoption = activity.findViewById(R.id.spinner_network_adoption);
Spinner resolution = activity.findViewById(R.id.spinner_resolution);
- CheckBox audio = activity.findViewById(R.id.checkbox_audio);
bindSpinner(activity, transport, new String[]{
activity.getString(R.string.transport_tcp),
@@ -78,8 +98,38 @@ public final class SettingsUi {
bindSpinner(activity, resolution, resolutions,
position -> settings.setResolution(CastSettings.Resolution.values()[position]));
resolution.setSelection(CastSettings.Resolution.HD_720P.ordinal());
+ }
- PlatformCastSupport.bindAudioCheckbox(activity, audio, settings);
+ private static void bindCaptureModeSender(AppCompatActivity activity, CastSettings settings) {
+ Spinner captureMode = activity.findViewById(R.id.spinner_capture_mode);
+ TextView label = activity.findViewById(R.id.text_capture_mode_label);
+ if (captureMode == null) {
+ return;
+ }
+ 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);
+ }
+ } 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));
+ }
+ }
}
private interface IntConsumer {
diff --git a/app/src/main/java/com/foxx/androidcast/VersionInfo.java b/app/src/main/java/com/foxx/androidcast/VersionInfo.java
new file mode 100644
index 0000000..d08c86c
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/VersionInfo.java
@@ -0,0 +1,18 @@
+package com.foxx.androidcast;
+
+/**
+ * Application and protocol version strings (CI may replace via script).
+ * App and proto versions are independent.
+ */
+public final class VersionInfo {
+ /** Human-readable app release (default until CI injects). */
+ public static final String APP_VERSION_STRING = "00.00.00.0000";
+
+ /** Human-readable wire protocol / header version. */
+ public static final String PROTO_VERSION_STRING = "00.01.00.0001";
+
+ public static final long APP_VERSION = VersionUtils.parseToInt(APP_VERSION_STRING);
+ public static final long PROTO_VERSION = VersionUtils.parseToInt(PROTO_VERSION_STRING);
+
+ private VersionInfo() {}
+}
diff --git a/app/src/main/java/com/foxx/androidcast/VersionUtils.java b/app/src/main/java/com/foxx/androidcast/VersionUtils.java
new file mode 100644
index 0000000..7568d11
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/VersionUtils.java
@@ -0,0 +1,64 @@
+package com.foxx.androidcast;
+
+import java.util.Locale;
+
+/**
+ * Version string format {@code PP.MM.mmmm.BBBB} (e.g. {@code 00.01.0003.5621}) packed into uint64.
+ * Layout: placeholder(2) · major(2) · minor(4) · build(4) decimal digits.
+ */
+public final class VersionUtils {
+ private VersionUtils() {}
+
+ public static String format(int placeholder, int major, int minor, int build) {
+ return String.format(Locale.US, "%02d.%02d.%04d.%04d",
+ clamp(placeholder, 0, 99),
+ clamp(major, 0, 99),
+ clamp(minor, 0, 9999),
+ clamp(build, 0, 9999));
+ }
+
+ public static long toInt(int placeholder, int major, int minor, int build) {
+ long ph = clamp(placeholder, 0, 99);
+ long maj = clamp(major, 0, 99);
+ long min = clamp(minor, 0, 9999);
+ long bld = clamp(build, 0, 9999);
+ return ph * 10_000_000_000L + maj * 100_000_000L + min * 10_000L + bld;
+ }
+
+ public static long parseToInt(String version) {
+ if (version == null || version.isEmpty()) {
+ return 0L;
+ }
+ String[] parts = version.split("\\.");
+ int ph = parts.length > 0 ? parsePart(parts[0], 0) : 0;
+ int maj = parts.length > 1 ? parsePart(parts[1], 0) : 0;
+ int min = parts.length > 2 ? parsePart(parts[2], 0) : 0;
+ int bld = parts.length > 3 ? parsePart(parts[3], 0) : 0;
+ return toInt(ph, maj, min, bld);
+ }
+
+ public static String formatFromInt(long packed) {
+ if (packed < 0) {
+ packed = 0;
+ }
+ int build = (int) (packed % 10_000L);
+ packed /= 10_000L;
+ int minor = (int) (packed % 10_000L);
+ packed /= 10_000L;
+ int major = (int) (packed % 100L);
+ int placeholder = (int) (packed / 100L);
+ return format(placeholder, major, minor, build);
+ }
+
+ private static int parsePart(String s, int fallback) {
+ try {
+ return Integer.parseInt(s.trim());
+ } catch (NumberFormatException e) {
+ return fallback;
+ }
+ }
+
+ private static int clamp(int v, int min, int max) {
+ return Math.max(min, Math.min(max, v));
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatter.java b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatter.java
new file mode 100644
index 0000000..9929806
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatter.java
@@ -0,0 +1,322 @@
+package com.foxx.androidcast.diagnostics;
+
+import com.foxx.androidcast.CastSettings;
+import com.foxx.androidcast.CastTuningEngine;
+import com.foxx.androidcast.media.CodecNegotiator;
+import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
+import com.foxx.androidcast.receiver.StreamMetrics;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** Builds HTML diagnostics overlay with red highlights for out-of-range metrics. */
+public final class CastDiagnosticsFormatter {
+ private static final String COLOR_OK = "#E0FFE0";
+ private static final String COLOR_WARN = "#FF6666";
+ private static final String COLOR_DIM = "#AAAAAA";
+
+ private CastDiagnosticsFormatter() {}
+
+ public static String formatHtml(StreamMetrics metrics, String transport, String videoCodec,
+ int streamW, int streamH, int renderW, int renderH, boolean idle,
+ CastSettings localSettings, CastSettings remoteSettings,
+ NetworkStatsSnapshot peer, NetworkStatsSnapshot lastLocal, int viewZoomPercent) {
+ float inFps = metrics.getInFps();
+ float renderFps = metrics.getRenderFps();
+ int bwInKbps = metrics.getBwInKbps();
+ long videoPackets = metrics.getVideoPackets();
+ long audioPackets = metrics.getAudioPackets();
+ long audioRendered = metrics.getAudioRenderedWrites();
+ long audioPcmSilent = metrics.getAudioPcmSilentBlocks();
+ long audioPcmAudible = metrics.getAudioPcmAudibleBlocks();
+ int audioVu = metrics.getAudioVuPercent();
+ float audioPeakDb = metrics.getAudioLastPeakDb();
+ float audioRmsDb = metrics.getAudioLastRmsDb();
+ long keyFrames = metrics.getKeyFrames();
+ long deltaFrames = metrics.getDeltaFrames();
+ int avgKb = metrics.getAvgKbPerFrame();
+ long idleMs = metrics.getLastFrameIdleMs();
+ int rttMs = metrics.getPeerRttMs();
+
+ CastSettings tuneSettings = remoteSettings != null ? remoteSettings : localSettings;
+ CastTuningEngine.EffectiveParams eff = CastTuningEngine.resolve(tuneSettings, peer);
+ int suggestedBwKbps = eff.videoBitrate / 1000;
+ int targetEncKbps = eff.videoBitrate / 1000;
+ int congestionLevel = 0;
+ float peerEncodeFps = 0f;
+ int peerSendKbps = metrics.getPeerSendKbps();
+ int peerRecvKbps = metrics.getPeerRecvKbps();
+ int peerQueue = 0;
+ int peerDroppedP = 0;
+ long peerAudioFrames = 0;
+ boolean peerAudioActive = false;
+ int peerCaptureVu = 0;
+ long peerCaptureSilent = 0;
+ long peerCaptureAudible = 0;
+ float peerCapturePeakDb = -96f;
+ float peerCaptureRmsDb = -96f;
+
+ if (peer != null) {
+ if (peer.rttMs > 0) {
+ rttMs = peer.rttMs;
+ }
+ peerSendKbps = peer.sendBitrateKbps;
+ peerRecvKbps = peer.recvBitrateKbps;
+ peerEncodeFps = peer.encodeFps;
+ peerQueue = peer.encodeQueueDepth;
+ peerDroppedP = peer.droppedPFrames;
+ peerAudioFrames = peer.audioFramesSent;
+ peerAudioActive = peer.audioActive;
+ congestionLevel = peer.congestionLevel;
+ if (peer.suggestedBitrateKbps > 0) {
+ suggestedBwKbps = peer.suggestedBitrateKbps;
+ }
+ if (peer.targetBitrateKbps > 0) {
+ targetEncKbps = peer.targetBitrateKbps;
+ }
+ peerCaptureVu = peer.audioCaptureVuPercent;
+ peerCaptureSilent = peer.audioCaptureSilentBlocks;
+ peerCaptureAudible = peer.audioCaptureAudibleBlocks;
+ peerCapturePeakDb = peer.audioCapturePeakDb;
+ peerCaptureRmsDb = peer.audioCaptureRmsDb;
+ }
+ if (lastLocal != null) {
+ congestionLevel = Math.max(congestionLevel, lastLocal.congestionLevel);
+ }
+
+ boolean audioRequested = remoteSettings != null && remoteSettings.isAudioRequested();
+ List hints = buildHints(inFps, renderFps, bwInKbps, rttMs, videoPackets);
+
+ int quality = CastDiagnosticsScorer.compute(inFps, renderFps, bwInKbps, suggestedBwKbps,
+ rttMs, congestionLevel, audioPackets, audioRequested);
+ String qualityColor = quality >= 70 ? COLOR_OK : (quality >= 45 ? "#FFCC66" : COLOR_WARN);
+
+ String bitrateMode = CastDiagnosticsLabels.bitrateMode(
+ localSettings != null ? localSettings.getBitrateMode() : CastSettings.BitrateMode.AUTO);
+ String netAdopt = CastDiagnosticsLabels.networkAdoption(
+ localSettings != null ? localSettings.getNetworkAdoption() : CastSettings.NetworkAdoption.AUTO);
+ String ctrl = CastDiagnosticsLabels.congestionController();
+
+ StringBuilder html = new StringBuilder();
+ html.append("Transport: ")
+ .append(escape(transport != null ? transport.toUpperCase() : "?"))
+ .append("
");
+
+ if (videoCodec != null && !videoCodec.isEmpty()) {
+ html.append("Codec: ")
+ .append(escape(CodecNegotiator.displayName(videoCodec))).append("
");
+ }
+
+ html.append("Stream: ").append(streamW).append('x').append(streamH);
+ if (viewZoomPercent != 0) {
+ html.append(" (").append(viewZoomPercent > 0 ? "+" : "").append(viewZoomPercent).append("%)");
+ }
+ if (renderW > 0 && renderH > 0 && (renderW != streamW || renderH != streamH)) {
+ html.append(" render: ").append(renderW).append('x').append(renderH);
+ }
+ html.append("
");
+
+ html.append("In FPS: ").append(colored(formatFloat(inFps), inFps > 0 && inFps < 15f));
+ html.append(" Render FPS: ").append(colored(formatFloat(renderFps),
+ inFps > 8f && renderFps > 0 && renderFps < inFps * 0.75f));
+ html.append("
");
+
+ html.append("BW in: ").append(colored(bwInKbps > 0 ? bwInKbps + " kbps" : "n/a",
+ suggestedBwKbps > 0 && bwInKbps > 0 && bwInKbps < suggestedBwKbps * 0.7f));
+ html.append(" RTT: ").append(colored(rttMs > 0 ? rttMs + " ms" : "n/a", rttMs > 350));
+ html.append("
");
+
+ html.append("Suggested BW: ").append(colored(
+ suggestedBwKbps > 0 ? suggestedBwKbps + " kbps" : "n/a",
+ bwInKbps > 0 && suggestedBwKbps > 0 && bwInKbps < suggestedBwKbps * 0.85f));
+ html.append(" Target enc: ").append(colored(
+ targetEncKbps > 0 ? targetEncKbps + " kbps" : "n/a", false));
+ html.append("
");
+
+ html.append("Congestion: ").append(colored(String.valueOf(congestionLevel), congestionLevel > 0));
+ html.append("
");
+ html.append("Ctrl: ").append(escape(ctrl));
+ html.append("
");
+ html.append("Mode: ").append(escape(bitrateMode));
+ html.append("
");
+ html.append("Net: ").append(escape(netAdopt));
+ html.append("
");
+
+ if (peerSendKbps > 0 || peerRecvKbps > 0 || peerEncodeFps > 0) {
+ html.append("Sender: ");
+ if (peerEncodeFps > 0) {
+ html.append("enc ").append(colored(formatFloat(peerEncodeFps), peerEncodeFps < 12f));
+ html.append(' ');
+ }
+ html.append("tx ").append(peerSendKbps).append(" / rx ").append(peerRecvKbps).append(" kbps");
+ if (peerQueue > 8 || peerDroppedP > 0) {
+ html.append(" q").append(peerQueue);
+ if (peerDroppedP > 0) {
+ html.append(" dropP=").append(peerDroppedP);
+ }
+ }
+ html.append("
");
+ if (audioRequested && (peerCaptureSilent > 0 || peerCaptureAudible > 0 || peerCaptureVu > 0)) {
+ html.append("Sender cap VU: ").append(vuBarHtml(peerCaptureVu)).append(' ')
+ .append(colored(peerCaptureVu + "%",
+ peerCaptureVu < 3 && peerCaptureSilent > peerCaptureAudible));
+ html.append(" peak ").append(formatDb(peerCapturePeakDb))
+ .append(" rms ").append(formatDb(peerCaptureRmsDb));
+ html.append("
Sender PCM: silent ")
+ .append(colored(String.valueOf(peerCaptureSilent),
+ peerCaptureSilent > 20 && peerCaptureAudible == 0))
+ .append(" audible ").append(String.valueOf(peerCaptureAudible));
+ if (peerCaptureSilent > 20 && peerCaptureAudible == 0) {
+ html.append(" ")
+ .append("(sender capture silent)")
+ .append("");
+ }
+ html.append("
");
+ }
+ }
+
+ if (remoteSettings != null) {
+ html.append("Sender prefs:
")
+ .append(escape(CastDiagnosticsLabels.adjustmentPreset(remoteSettings.getAdjustmentPreset())))
+ .append("
")
+ .append(escape(CastDiagnosticsLabels.bitrateMode(remoteSettings.getBitrateMode())))
+ .append("
")
+ .append(escape(CastDiagnosticsLabels.resolution(remoteSettings.getResolution())))
+ .append("
")
+ .append(escape(CastDiagnosticsLabels.videoCodec(remoteSettings.getVideoCodec())))
+ .append("
")
+ .append(escape(CastDiagnosticsLabels.captureMode(remoteSettings.getCaptureMode())));
+ if (audioRequested) {
+ html.append(" · audio");
+ if (peerAudioActive) {
+ html.append(" (").append(peerAudioFrames).append(" frm)");
+ }
+ }
+ html.append("
");
+ }
+
+ html.append("Video pkts: ").append(videoPackets);
+ html.append(" (IDR ").append(keyFrames).append(" / P ").append(deltaFrames).append(')');
+ if (videoPackets > 0) {
+ html.append(" ~").append(Math.max(1, avgKb)).append(" KB/frame");
+ }
+ html.append("
");
+
+ html.append("Audio pkts: ").append(colored(String.valueOf(audioPackets),
+ audioRequested && audioPackets == 0 && videoPackets > 20));
+ html.append(" rendered: ").append(colored(String.valueOf(audioRendered),
+ audioRequested && audioPackets > 5 && audioRendered == 0));
+ if (audioRequested && audioPackets > 0 && audioRendered == 0) {
+ html.append(" (check receiver volume)");
+ }
+ if (audioRequested && audioPackets == 0 && videoPackets > 20) {
+ html.append(" ")
+ .append("(play media on sender)")
+ .append("");
+ } else if (audioRequested && audioPackets > 10 && audioRendered == 0) {
+ html.append(" ")
+ .append("(decode/render stalled)")
+ .append("");
+ } else if (audioRequested && audioRendered > 15 && audioPcmAudible == 0
+ && audioPcmSilent > 15) {
+ html.append(" ")
+ .append("(decoded PCM is silence)")
+ .append("");
+ if (peerCaptureSilent > 20 && peerCaptureAudible == 0) {
+ html.append(" ")
+ .append("(fix on sender: Choose app, Android 14+)")
+ .append("");
+ }
+ }
+ html.append("
");
+
+ if (audioRequested && audioRendered > 0) {
+ html.append("Audio VU: ").append(vuBarHtml(audioVu)).append(' ')
+ .append(colored(audioVu + "%", audioVu < 3 && audioPcmSilent > audioPcmAudible));
+ html.append(" peak ").append(formatDb(audioPeakDb))
+ .append(" rms ").append(formatDb(audioRmsDb));
+ html.append("
PCM blocks: silent ")
+ .append(colored(String.valueOf(audioPcmSilent),
+ audioPcmSilent > 20 && audioPcmAudible == 0))
+ .append(" audible ").append(String.valueOf(audioPcmAudible));
+ html.append("
");
+ }
+
+ for (String hint : hints) {
+ html.append("")
+ .append(escape(hint)).append("
");
+ }
+
+ html.append("Quality: ")
+ .append(quality).append(" — ").append(CastDiagnosticsScorer.label(quality))
+ .append("
");
+
+ html.append("State: ")
+ .append(idle ? "idle (awaiting)" : "streaming");
+ if (idleMs >= 0) {
+ html.append(" last frame: ").append(idleMs).append(" ms ago");
+ }
+ html.append("");
+
+ return html.toString();
+ }
+
+ private static List buildHints(float inFps, float renderFps, int bwInKbps,
+ int rttMs, long videoPackets) {
+ List hints = new ArrayList<>();
+ if (inFps > 0 && inFps < 8f) {
+ hints.add("Hint: low In FPS → sender encode stall / TCP backlog");
+ }
+ if (bwInKbps > 0 && bwInKbps < 350 && inFps < 8f && videoPackets > 30) {
+ hints.add("Hint: very low BW — static screen or throttled encoder");
+ }
+ if (rttMs > 450 && inFps < 15f) {
+ hints.add("Hint: high RTT — Wi‑Fi congestion");
+ }
+ if (inFps > 12f && renderFps > 0 && renderFps < inFps * 0.7f) {
+ hints.add("Hint: decode slower than network (try H.264 or lower res)");
+ }
+ return hints;
+ }
+
+ private static String colored(String text, boolean bad) {
+ return "" + escape(text) + "";
+ }
+
+ private static String escape(String s) {
+ if (s == null) {
+ return "";
+ }
+ return s.replace("&", "&").replace("<", "<");
+ }
+
+ private static String formatFloat(float v) {
+ if (v <= 0f) {
+ return "n/a";
+ }
+ return String.format(java.util.Locale.US, "%.1f", v);
+ }
+
+ private static String formatDb(float db) {
+ if (db <= -90f) {
+ return "-∞";
+ }
+ return String.format(java.util.Locale.US, "%.0f dB", db);
+ }
+
+ private static String vuBarHtml(int percent) {
+ int width = 16;
+ int filled = Math.min(width, (percent * width + 50) / 100);
+ StringBuilder b = new StringBuilder("[");
+ for (int i = 0; i < width; i++) {
+ if (i < filled) {
+ String c = i < width * 2 / 3 ? "#66FF66" : (i < width * 5 / 6 ? "#FFCC00" : "#FF4444");
+ b.append("█");
+ } else {
+ b.append("░");
+ }
+ }
+ b.append("]");
+ return b.toString();
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsLabels.java b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsLabels.java
new file mode 100644
index 0000000..b01c36f
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsLabels.java
@@ -0,0 +1,125 @@
+package com.foxx.androidcast.diagnostics;
+
+import com.foxx.androidcast.CastSettings;
+
+/** Human-readable names for diagnostics (no bare AUTO enum tokens). */
+public final class CastDiagnosticsLabels {
+ private CastDiagnosticsLabels() {}
+
+ public static String congestionController() {
+ return "PassThroughNetworkControlPlane (heartbeat stats)";
+ }
+
+ public static String bitrateMode(CastSettings.BitrateMode mode) {
+ if (mode == null) {
+ return "CastTuningEngine (default)";
+ }
+ switch (mode) {
+ case NONE:
+ return "Uncapped encoder bitrate";
+ case QUALITY:
+ return "Quality bias (+25% bitrate)";
+ case SPEED:
+ return "Speed bias (−35% bitrate, ≤24 fps)";
+ case BALANCED:
+ return "Balanced preset";
+ case ROBUST_LESS:
+ return "RobustLess (UDP, −45% bitrate)";
+ case ESTIMATED:
+ return "CastTuningEngine bandwidth estimate";
+ case AUTO:
+ default:
+ return "CastTuningEngine (preset + peer stats)";
+ }
+ }
+
+ public static String networkAdoption(CastSettings.NetworkAdoption mode) {
+ if (mode == null) {
+ return "No extra network scaling";
+ }
+ switch (mode) {
+ case QUALITY:
+ return "Quality bias (+10% bitrate)";
+ case SPEED:
+ return "Speed bias (−30% bitrate)";
+ case ESTIMATED:
+ return "CastTuningEngine recv-bitrate cap";
+ case AUTO:
+ default:
+ return "No extra network scaling";
+ }
+ }
+
+ public static String adjustmentPreset(CastSettings.AdjustmentPreset preset) {
+ if (preset == null) {
+ return "CastTuningEngine default";
+ }
+ switch (preset) {
+ case NONE:
+ return "No preset adjustments";
+ case QUALITY:
+ return "Quality (+15% bitrate, +2 fps)";
+ case SPEED:
+ return "Speed (−15% bitrate)";
+ case PERFORMANCE:
+ return "Performance (−15% bitrate, stable fps)";
+ case AUTO:
+ default:
+ return "CastTuningEngine adaptive preset";
+ }
+ }
+
+ public static String resolution(CastSettings.Resolution res) {
+ if (res == null) {
+ return "Device default";
+ }
+ switch (res) {
+ case FULL:
+ return "Full display";
+ case THREE_QUARTERS:
+ return "75% display scale";
+ case HALF:
+ return "50% display scale";
+ case HD_720P:
+ return "720p long-edge cap";
+ case FIXED_1080P:
+ return "Fixed 1080p box";
+ case FIXED_720P:
+ return "Fixed 720p box";
+ case ADAPTIVE:
+ return "Adaptive (CastTuningEngine + peer)";
+ default:
+ return res.name();
+ }
+ }
+
+ public static String videoCodec(CastSettings.VideoCodec codec) {
+ if (codec == null) {
+ return "CodecNegotiator preference list";
+ }
+ switch (codec) {
+ case H264:
+ return "H.264 (forced)";
+ case H265:
+ return "H.265 (forced)";
+ case PASSTHROUGH:
+ return "Passthrough (debug, unimplemented)";
+ case AUTO:
+ default:
+ return "CodecNegotiator (HEVC→AVC→VP9→VP8)";
+ }
+ }
+
+ public static String captureMode(CastSettings.CaptureMode mode) {
+ if (mode == null) {
+ return "Entire screen";
+ }
+ switch (mode) {
+ case USER_CHOICE:
+ return "Choose app or screen (Android 14+)";
+ case FULL_SCREEN:
+ default:
+ return "Entire screen";
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsScorer.java b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsScorer.java
new file mode 100644
index 0000000..951a912
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsScorer.java
@@ -0,0 +1,62 @@
+package com.foxx.androidcast.diagnostics;
+
+/** Heuristic stream quality score (0–100) from live metrics. */
+public final class CastDiagnosticsScorer {
+ private CastDiagnosticsScorer() {}
+
+ public static int compute(float inFps, float renderFps, int bwInKbps, int suggestedBwKbps,
+ int rttMs, int congestionLevel, long audioPackets, boolean audioRequested) {
+ int score = 100;
+ if (inFps > 0 && inFps < 12f) {
+ score -= 25;
+ } else if (inFps > 0 && inFps < 20f) {
+ score -= 12;
+ }
+ if (inFps > 5f && renderFps > 0 && renderFps < inFps * 0.7f) {
+ score -= 22;
+ } else if (inFps > 5f && renderFps > 0 && renderFps < inFps * 0.85f) {
+ score -= 10;
+ }
+ if (suggestedBwKbps > 0 && bwInKbps > 0 && bwInKbps < suggestedBwKbps * 0.55f) {
+ score -= 20;
+ } else if (suggestedBwKbps > 0 && bwInKbps > 0 && bwInKbps < suggestedBwKbps * 0.75f) {
+ score -= 10;
+ }
+ if (rttMs > 600) {
+ score -= 18;
+ } else if (rttMs > 350) {
+ score -= 8;
+ }
+ if (congestionLevel >= 2) {
+ score -= 15;
+ } else if (congestionLevel >= 1) {
+ score -= 6;
+ }
+ if (audioRequested && audioPackets == 0) {
+ score -= 8;
+ }
+ if (score < 0) {
+ return 0;
+ }
+ if (score > 100) {
+ return 100;
+ }
+ return score;
+ }
+
+ public static String label(int score) {
+ if (score >= 85) {
+ return "Excellent";
+ }
+ if (score >= 70) {
+ return "Good";
+ }
+ if (score >= 50) {
+ return "Fair";
+ }
+ if (score >= 30) {
+ return "Poor";
+ }
+ return "Bad";
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java b/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java
index b16b843..044494c 100644
--- a/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java
+++ b/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java
@@ -7,6 +7,11 @@ import android.os.Looper;
import android.util.Log;
import com.foxx.androidcast.CastConfig;
+import com.foxx.androidcast.DeviceInfo;
+import com.foxx.androidcast.network.CastCodecFlags;
+import com.foxx.androidcast.network.CastPacketFramer;
+import com.foxx.androidcast.network.CastProtoHeader;
+import com.foxx.androidcast.network.CastProtocol;
import org.json.JSONObject;
@@ -28,6 +33,14 @@ public class DiscoveryManager {
public interface Listener {
void onDevicesUpdated(List devices);
+
+ /** UDP browse socket is active. */
+ default void onScanStarted() {
+ }
+
+ /** Browse pass finished (timeout or {@link #stop()}). */
+ default void onScanEnded() {
+ }
}
public static final class DiscoveredDevice {
@@ -36,13 +49,26 @@ public class DiscoveryManager {
public final int port;
public final String transport;
public final long lastSeenMs;
+ public final int videoCodecs;
+ public final int audioCodecs;
+ public final int placeholderId;
+ public final long protoVersion;
public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs) {
+ this(name, host, port, transport, lastSeenMs, 0, 0, 0, 0L);
+ }
+
+ public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs,
+ int videoCodecs, int audioCodecs, int placeholderId, long protoVersion) {
this.name = name;
this.host = host;
this.port = port;
this.transport = transport;
this.lastSeenMs = lastSeenMs;
+ this.videoCodecs = videoCodecs;
+ this.audioCodecs = audioCodecs;
+ this.placeholderId = placeholderId;
+ this.protoVersion = protoVersion;
}
}
@@ -70,6 +96,7 @@ public class DiscoveryManager {
if (!running.compareAndSet(false, true)) {
return;
}
+ notifyScanStarted();
executor.execute(this::browseLoop);
}
@@ -99,11 +126,16 @@ public class DiscoveryManager {
startAnnouncing(deviceName, port, transport, true);
}
- public void startAnnouncing(String deviceName, int port, String transport, boolean listening) {
+ public void startAnnouncing(Context context, String deviceName, int port, String transport,
+ boolean listening) {
if (!running.compareAndSet(false, true)) {
return;
}
- executor.execute(() -> announceLoop(deviceName, port, transport, listening));
+ executor.execute(() -> announceLoop(context, deviceName, port, transport, listening));
+ }
+
+ public void startAnnouncing(String deviceName, int port, String transport, boolean listening) {
+ startAnnouncing(appContext, deviceName, port, transport, listening);
}
private void browseLoop() {
@@ -113,11 +145,17 @@ public class DiscoveryManager {
sock.setReuseAddress(true);
sock.bind(new InetSocketAddress(CastConfig.DISCOVERY_PORT));
sock.setBroadcast(true);
+ sock.setSoTimeout(300);
byte[] buf = new byte[1024];
- while (running.get()) {
+ long deadline = System.currentTimeMillis() + CastConfig.DISCOVERY_SCAN_PASS_MS;
+ while (running.get() && System.currentTimeMillis() < deadline) {
DatagramPacket packet = new DatagramPacket(buf, buf.length);
- sock.receive(packet);
- handlePacket(packet);
+ try {
+ sock.receive(packet);
+ handlePacket(packet);
+ } catch (java.net.SocketTimeoutException ignored) {
+ // keep listening until pass ends
+ }
}
} catch (Exception e) {
if (running.get()) {
@@ -127,11 +165,38 @@ public class DiscoveryManager {
socket = null;
releaseMulticastLock();
running.set(false);
+ notifyScanEnded();
}
}
- private void announceLoop(String deviceName, int port, String transport, boolean listening) {
+ private void notifyScanStarted() {
+ if (listener == null) {
+ return;
+ }
+ mainHandler.post(() -> {
+ if (listener != null) {
+ listener.onScanStarted();
+ }
+ });
+ }
+
+ private void notifyScanEnded() {
+ if (listener == null) {
+ return;
+ }
+ mainHandler.post(() -> {
+ if (listener != null) {
+ listener.onScanEnded();
+ }
+ });
+ }
+
+ private void announceLoop(Context context, String deviceName, int port, String transport,
+ boolean listening) {
acquireMulticastLock();
+ int placeholderId = DeviceInfo.getPlaceholderId(context);
+ int videoMask = CastCodecFlags.localDecoderVideoMask();
+ int audioMask = CastCodecFlags.localAudioMask();
try (DatagramSocket sock = new DatagramSocket()) {
socket = sock;
sock.setBroadcast(true);
@@ -142,9 +207,17 @@ public class DiscoveryManager {
json.put("port", port);
json.put("transport", transport);
json.put("listening", listening);
- byte[] data = json.toString().getBytes(StandardCharsets.UTF_8);
+ 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;
+ }
InetAddress broadcast = InetAddress.getByName("255.255.255.255");
- DatagramPacket packet = new DatagramPacket(data, data.length, broadcast, CastConfig.DISCOVERY_PORT);
+ DatagramPacket packet = new DatagramPacket(wire, wire.length, broadcast, CastConfig.DISCOVERY_PORT);
sock.send(packet);
Thread.sleep(2000);
}
@@ -161,8 +234,40 @@ public class DiscoveryManager {
private void handlePacket(DatagramPacket packet) {
try {
- String text = new String(packet.getData(), packet.getOffset(), packet.getLength(),
- StandardCharsets.UTF_8);
+ byte[] raw = packet.getData();
+ int off = packet.getOffset();
+ int len = packet.getLength();
+ String text;
+ int videoCodecs = 0;
+ int audioCodecs = 0;
+ int placeholderId = 0;
+ long protoVersion = 0L;
+ int bodyOff = off;
+ int bodyLen = len;
+ if (len >= CastProtoHeader.HEADER_SIZE_V1 && readMagicLe(raw, off) == CastProtoHeader.MAGIC) {
+ CastProtoHeader hdr = CastProtoHeader.decode(raw, off, len);
+ if (hdr.verifyChecksum() && hdr.hsType == CastProtoHeader.HS_SDP) {
+ videoCodecs = hdr.videoCodecs;
+ audioCodecs = hdr.audioCodecs;
+ placeholderId = hdr.placeholderId;
+ protoVersion = hdr.protoVersion;
+ }
+ bodyOff = off + hdr.headerSize;
+ bodyLen = len - hdr.headerSize;
+ if (bodyLen > 5) {
+ byte type = raw[bodyOff];
+ int plen = readIntBe(raw, bodyOff + 1);
+ if (plen >= 0 && bodyOff + 5 + plen <= off + len) {
+ text = new String(raw, bodyOff + 5, plen, StandardCharsets.UTF_8);
+ } else {
+ text = new String(raw, bodyOff, bodyLen, StandardCharsets.UTF_8);
+ }
+ } else {
+ text = new String(raw, bodyOff, bodyLen, StandardCharsets.UTF_8);
+ }
+ } else {
+ text = new String(raw, off, len, StandardCharsets.UTF_8);
+ }
JSONObject json = new JSONObject(text);
if (!CastConfig.DISCOVERY_MAGIC.equals(json.optString("magic"))) {
return;
@@ -175,13 +280,24 @@ public class DiscoveryManager {
}
String host = packet.getAddress().getHostAddress();
String key = host + ":" + port;
- DiscoveredDevice device = new DiscoveredDevice(name, host, port, transport, System.currentTimeMillis());
+ DiscoveredDevice device = new DiscoveredDevice(name, host, port, transport,
+ System.currentTimeMillis(), videoCodecs, audioCodecs, placeholderId, protoVersion);
devices.put(key, device);
notifyListener();
} catch (Exception ignored) {
}
}
+ private static int readMagicLe(byte[] b, int off) {
+ return (b[off] & 0xff) | ((b[off + 1] & 0xff) << 8)
+ | ((b[off + 2] & 0xff) << 16) | ((b[off + 3] & 0xff) << 24);
+ }
+
+ private static int readIntBe(byte[] b, int off) {
+ return ((b[off] & 0xff) << 24) | ((b[off + 1] & 0xff) << 16)
+ | ((b[off + 2] & 0xff) << 8) | (b[off + 3] & 0xff);
+ }
+
private void notifyListener() {
if (listener == null) {
return;
diff --git a/app/src/main/java/com/foxx/androidcast/media/AacAdtsHelper.java b/app/src/main/java/com/foxx/androidcast/media/AacAdtsHelper.java
new file mode 100644
index 0000000..4c4e5f0
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/media/AacAdtsHelper.java
@@ -0,0 +1,60 @@
+package com.foxx.androidcast.media;
+
+/** Prepends ADTS sync word so AAC decoders accept encoder access units. */
+public final class AacAdtsHelper {
+ private AacAdtsHelper() {}
+
+ public static byte[] wrapIfNeeded(byte[] aacFrame, int sampleRate, int channelCount) {
+ if (aacFrame == null || aacFrame.length == 0) {
+ return aacFrame;
+ }
+ if (aacFrame.length >= 2 && (aacFrame[0] & 0xFF) == 0xFF && (aacFrame[1] & 0xF0) == 0xF0) {
+ return aacFrame;
+ }
+ int profile = 2;
+ int freqIdx = sampleRateIndex(sampleRate);
+ int chanCfg = Math.max(1, Math.min(7, channelCount));
+ int frameLen = aacFrame.length + 7;
+ byte[] adts = new byte[frameLen];
+ adts[0] = (byte) 0xFF;
+ adts[1] = (byte) 0xF1;
+ adts[2] = (byte) (((profile - 1) << 6) + (freqIdx << 2) + (chanCfg >> 2));
+ adts[3] = (byte) (((chanCfg & 3) << 6) + (frameLen >> 11));
+ adts[4] = (byte) ((frameLen & 0x7FF) >> 3);
+ adts[5] = (byte) (((frameLen & 7) << 5) + 0x1F);
+ adts[6] = (byte) 0xFC;
+ System.arraycopy(aacFrame, 0, adts, 7, aacFrame.length);
+ return adts;
+ }
+
+ private static int sampleRateIndex(int sampleRate) {
+ switch (sampleRate) {
+ case 96000:
+ return 0;
+ case 88200:
+ return 1;
+ case 64000:
+ return 2;
+ case 48000:
+ return 3;
+ case 44100:
+ return 4;
+ case 32000:
+ return 5;
+ case 24000:
+ return 6;
+ case 22050:
+ return 7;
+ case 16000:
+ return 8;
+ case 12000:
+ return 9;
+ case 11025:
+ return 10;
+ case 8000:
+ return 11;
+ default:
+ return 4;
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/media/PcmLevelAnalyzer.java b/app/src/main/java/com/foxx/androidcast/media/PcmLevelAnalyzer.java
new file mode 100644
index 0000000..dd8aaac
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/media/PcmLevelAnalyzer.java
@@ -0,0 +1,67 @@
+package com.foxx.androidcast.media;
+
+/** Peak/RMS analysis for 16-bit little-endian PCM. */
+public final class PcmLevelAnalyzer {
+ /** ~-46 dBFS; below this counts as silence. */
+ public static final float SILENT_PEAK_THRESHOLD = 0.005f;
+
+ public static final class Levels {
+ public final float peak;
+ public final float rms;
+ public final boolean silent;
+
+ Levels(float peak, float rms, boolean silent) {
+ this.peak = peak;
+ this.rms = rms;
+ this.silent = silent;
+ }
+
+ public float peakDb() {
+ if (peak <= 1e-9f) {
+ return -96f;
+ }
+ return (float) (20.0 * Math.log10(peak));
+ }
+ }
+
+ private PcmLevelAnalyzer() {}
+
+ public static Levels analyze(byte[] pcm, int length) {
+ if (pcm == null || length < 2) {
+ return new Levels(0f, 0f, true);
+ }
+ int samples = length / 2;
+ long sumSq = 0;
+ int peakAbs = 0;
+ for (int i = 0; i + 1 < length; i += 2) {
+ short s = (short) ((pcm[i] & 0xff) | (pcm[i + 1] << 8));
+ int a = Math.abs(s);
+ if (a > peakAbs) {
+ peakAbs = a;
+ }
+ sumSq += (long) s * s;
+ }
+ float peak = peakAbs / 32768f;
+ float rms = samples > 0
+ ? (float) Math.sqrt(sumSq / (double) samples) / 32768f
+ : 0f;
+ boolean silent = peak < SILENT_PEAK_THRESHOLD && rms < SILENT_PEAK_THRESHOLD * 0.7f;
+ return new Levels(peak, rms, silent);
+ }
+
+ /** 0–100 visual level from RMS (scaled for speech/music). */
+ public static int vuPercent(float rms) {
+ if (rms <= 0f) {
+ return 0;
+ }
+ float db = (float) (20.0 * Math.log10(rms + 1e-9));
+ float norm = (db + 60f) / 60f;
+ if (norm < 0f) {
+ norm = 0f;
+ }
+ if (norm > 1f) {
+ norm = 1f;
+ }
+ return Math.round(norm * 100f);
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/network/CastCodecFlags.java b/app/src/main/java/com/foxx/androidcast/network/CastCodecFlags.java
new file mode 100644
index 0000000..e0feba0
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/network/CastCodecFlags.java
@@ -0,0 +1,53 @@
+package com.foxx.androidcast.network;
+
+import android.media.MediaFormat;
+
+import com.foxx.androidcast.sender.CodecCatalog;
+
+import java.util.List;
+
+/** Video/audio codec bitmasks in {@link CastProtoHeader}. */
+public final class CastCodecFlags {
+ public static final int VIDEO_UNKNOWN = 0;
+ public static final int VIDEO_H264 = 1;
+ public static final int VIDEO_H265 = 2;
+ public static final int VIDEO_VP8 = 4;
+ public static final int VIDEO_VP9 = 8;
+
+ public static final int AUDIO_UNKNOWN = 0;
+ public static final int AUDIO_PCM = 1;
+ public static final int AUDIO_AAC = 2;
+
+ private CastCodecFlags() {}
+
+ public static int videoMaskFromMimes(List mimes) {
+ int mask = 0;
+ if (mimes == null) {
+ return mask;
+ }
+ for (String mime : mimes) {
+ if (MediaFormat.MIMETYPE_VIDEO_AVC.equalsIgnoreCase(mime)) {
+ mask |= VIDEO_H264;
+ } else if (MediaFormat.MIMETYPE_VIDEO_HEVC.equalsIgnoreCase(mime)) {
+ mask |= VIDEO_H265;
+ } else if (MediaFormat.MIMETYPE_VIDEO_VP8.equalsIgnoreCase(mime)) {
+ mask |= VIDEO_VP8;
+ } else if (MediaFormat.MIMETYPE_VIDEO_VP9.equalsIgnoreCase(mime)) {
+ mask |= VIDEO_VP9;
+ }
+ }
+ return mask;
+ }
+
+ public static int localVideoMask() {
+ return videoMaskFromMimes(CodecCatalog.localEncoderMimes());
+ }
+
+ public static int localDecoderVideoMask() {
+ return videoMaskFromMimes(CodecCatalog.localDecoderMimes());
+ }
+
+ public static int localAudioMask() {
+ return AUDIO_AAC | AUDIO_PCM;
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/network/CastFramingContext.java b/app/src/main/java/com/foxx/androidcast/network/CastFramingContext.java
new file mode 100644
index 0000000..491a514
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/network/CastFramingContext.java
@@ -0,0 +1,26 @@
+package com.foxx.androidcast.network;
+
+/** Per-session values written into {@link CastProtoHeader}. */
+public final class CastFramingContext {
+ public final int sessionId;
+ public final int placeholderId;
+ public final int videoCodecs;
+ public final int audioCodecs;
+
+ public CastFramingContext(int sessionId, int placeholderId, int videoCodecs, int audioCodecs) {
+ this.sessionId = sessionId;
+ this.placeholderId = placeholderId;
+ this.videoCodecs = videoCodecs;
+ this.audioCodecs = audioCodecs;
+ }
+
+ public CastProtoHeader newHeader(byte hsType) {
+ CastProtoHeader h = new CastProtoHeader();
+ h.hsType = hsType;
+ h.sessionId = sessionId;
+ h.placeholderId = placeholderId;
+ h.videoCodecs = videoCodecs;
+ h.audioCodecs = audioCodecs;
+ return h;
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/network/CastPacketFramer.java b/app/src/main/java/com/foxx/androidcast/network/CastPacketFramer.java
new file mode 100644
index 0000000..9fd50bc
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/network/CastPacketFramer.java
@@ -0,0 +1,104 @@
+package com.foxx.androidcast.network;
+
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+
+/** Wraps {@link CastProtoHeader} + {@link CastProtocol} message body. */
+public final class CastPacketFramer {
+ private CastPacketFramer() {}
+
+ public static byte[] frame(CastProtoHeader header, byte type, byte[] payload) throws IOException {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ DataOutputStream dos = new DataOutputStream(bos);
+ CastProtocol.writeMessage(dos, type, payload);
+ byte[] inner = bos.toByteArray();
+ header.recomputeChecksum();
+ byte[] hdr = header.encode();
+ byte[] out = new byte[hdr.length + inner.length];
+ System.arraycopy(hdr, 0, out, 0, hdr.length);
+ System.arraycopy(inner, 0, out, hdr.length, inner.length);
+ return out;
+ }
+
+ 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 = CastProtoHeader.decode(wire, 0, wire.length);
+ if (!hdr.verifyChecksum()) {
+ throw new IOException("Header checksum mismatch");
+ }
+ int innerOff = hdr.headerSize;
+ if (wire.length < innerOff + 5) {
+ throw new IOException("Missing message body");
+ }
+ DataInputStream dis = new DataInputStream(
+ new java.io.ByteArrayInputStream(wire, innerOff, wire.length - innerOff));
+ return CastProtocol.readMessage(dis);
+ }
+
+ /**
+ * Reads one framed message from a TCP stream (blocking).
+ * Returns null if stream ends before a full frame.
+ */
+ public static CastProtocol.Message readFramedMessage(InputStream in) throws IOException {
+ byte[] magicBuf = readFully(in, 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(in, 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");
+ }
+ byte type = (byte) in.read();
+ if (type < 0) {
+ return null;
+ }
+ byte[] lenBuf = readFully(in, 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(in, len);
+ if (payload == null && len > 0) {
+ return null;
+ }
+ return new CastProtocol.Message(type, payload);
+ }
+
+ 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 EOFException("Short read");
+ }
+ off += r;
+ }
+ return buf;
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/network/CastProtoHeader.java b/app/src/main/java/com/foxx/androidcast/network/CastProtoHeader.java
new file mode 100644
index 0000000..0bffc8d
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/network/CastProtoHeader.java
@@ -0,0 +1,131 @@
+package com.foxx.androidcast.network;
+
+import com.foxx.androidcast.VersionInfo;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.zip.CRC32;
+
+/**
+ * Fixed binary header prefixed to every cast/discovery packet (little-endian).
+ * v1 size = 48 bytes.
+ */
+public final class CastProtoHeader {
+ public static final int MAGIC = 0xDEADBABE;
+ public static final int HEADER_SIZE_V1 = 48;
+
+ public static final byte HS_DEFAULT = 0;
+ public static final byte HS_SDP = 1;
+
+ public int magic = MAGIC;
+ public long protoVersion = VersionInfo.PROTO_VERSION;
+ public int headerSize = HEADER_SIZE_V1;
+ public int flags;
+ public int reserved;
+ public byte hsType = HS_DEFAULT;
+ public int videoCodecs;
+ public int audioCodecs;
+ public int placeholderId;
+ public int sessionId;
+ public int checksum;
+
+ public static CastProtoHeader forCast(int sessionId, int placeholderId, int videoCodecs, int audioCodecs) {
+ CastProtoHeader h = new CastProtoHeader();
+ h.hsType = HS_DEFAULT;
+ h.sessionId = sessionId;
+ h.placeholderId = placeholderId;
+ h.videoCodecs = videoCodecs;
+ h.audioCodecs = audioCodecs;
+ h.recomputeChecksum();
+ return h;
+ }
+
+ public static CastProtoHeader forDiscovery(int placeholderId, int videoCodecs, int audioCodecs) {
+ CastProtoHeader h = new CastProtoHeader();
+ h.hsType = HS_SDP;
+ h.sessionId = 0;
+ h.placeholderId = placeholderId;
+ h.videoCodecs = videoCodecs;
+ h.audioCodecs = audioCodecs;
+ h.recomputeChecksum();
+ return h;
+ }
+
+ public void recomputeChecksum() {
+ checksum = 0;
+ checksum = (int) crc32(encode());
+ }
+
+ public boolean verifyChecksum() {
+ int saved = checksum;
+ checksum = 0;
+ int expected = (int) crc32(encode());
+ checksum = saved;
+ return saved == expected;
+ }
+
+ public byte[] encode() {
+ ByteBuffer buf = ByteBuffer.allocate(HEADER_SIZE_V1).order(ByteOrder.LITTLE_ENDIAN);
+ buf.putInt(magic);
+ buf.putLong(protoVersion);
+ buf.putInt(headerSize);
+ buf.putInt(flags);
+ buf.putInt(reserved);
+ buf.put(hsType);
+ buf.put((byte) 0);
+ buf.put((byte) 0);
+ buf.put((byte) 0);
+ buf.putInt(videoCodecs);
+ buf.putInt(audioCodecs);
+ buf.putInt(placeholderId);
+ buf.putInt(sessionId);
+ buf.putInt(0);
+ byte[] out = buf.array();
+ checksum = (int) crc32(out);
+ ByteBuffer.wrap(out).order(ByteOrder.LITTLE_ENDIAN).putInt(44, checksum);
+ return out;
+ }
+
+ public static CastProtoHeader decode(byte[] bytes, int offset, int length) throws java.io.IOException {
+ if (length < HEADER_SIZE_V1) {
+ throw new java.io.IOException("Header too short: " + length);
+ }
+ ByteBuffer buf = ByteBuffer.wrap(bytes, offset, length).order(ByteOrder.LITTLE_ENDIAN);
+ CastProtoHeader h = new CastProtoHeader();
+ h.magic = buf.getInt();
+ if (h.magic != MAGIC) {
+ throw new java.io.IOException("Bad magic: 0x" + Integer.toHexString(h.magic));
+ }
+ h.protoVersion = buf.getLong();
+ h.headerSize = buf.getInt();
+ h.flags = buf.getInt();
+ h.reserved = buf.getInt();
+ h.hsType = buf.get();
+ buf.get();
+ buf.get();
+ buf.get();
+ h.videoCodecs = buf.getInt();
+ h.audioCodecs = buf.getInt();
+ h.placeholderId = buf.getInt();
+ h.sessionId = buf.getInt();
+ h.checksum = buf.getInt();
+ if (h.headerSize < HEADER_SIZE_V1) {
+ h.headerSize = HEADER_SIZE_V1;
+ }
+ return h;
+ }
+
+ private static long crc32(byte[] data) {
+ CRC32 crc = new CRC32();
+ int checksumOffset = 44;
+ crc.update(data, 0, checksumOffset);
+ crc.update((byte) 0);
+ crc.update((byte) 0);
+ crc.update((byte) 0);
+ crc.update((byte) 0);
+ if (data.length > checksumOffset + 4) {
+ crc.update(data, checksumOffset + 4, data.length - checksumOffset - 4);
+ }
+ return crc.getValue();
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java
index afa27a3..a685620 100644
--- a/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java
+++ b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java
@@ -31,6 +31,8 @@ public final class CastProtocol {
public static final byte MSG_AUDIO_CONSENT = 13;
public static final byte MSG_CODEC_CAPS = 14;
public static final byte MSG_CODEC_SELECTED = 15;
+ /** UDP outer wrapper: payload is {@link CastPacketFramer} wire bytes. */
+ public static final byte MSG_WIRE_PACKET = 127;
private CastProtocol() {}
@@ -77,6 +79,7 @@ public final class CastProtocol {
dos.writeInt(settings.getAdjustmentPreset().ordinal());
dos.writeInt(settings.getBitrateMode().ordinal());
dos.writeInt(settings.getNetworkAdoption().ordinal());
+ dos.writeInt(settings.getCaptureMode().ordinal());
dos.flush();
return bos.toByteArray();
}
@@ -108,6 +111,10 @@ public final class CastProtocol {
settings.setNetworkAdoption(readEnum(CastSettings.NetworkAdoption.values(), dis.readInt(),
CastSettings.NetworkAdoption.AUTO));
}
+ if (dis.available() > 0) {
+ settings.setCaptureMode(readEnum(CastSettings.CaptureMode.values(), dis.readInt(),
+ CastSettings.CaptureMode.FULL_SCREEN));
+ }
} else {
dis.reset();
settings.setTransport(dis.readUTF());
@@ -248,6 +255,8 @@ public final class CastProtocol {
return local.getTransport().equals(remote.getTransport());
}
+ private static final byte NETWORK_STATS_VERSION = 3;
+
public static byte[] networkStatsPayload(NetworkStatsSnapshot s) throws IOException {
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos);
@@ -259,6 +268,21 @@ public final class CastProtocol {
dos.writeInt(s.recvBitrateKbps);
dos.writeInt(s.congestionLevel);
dos.writeInt(s.jitterMs);
+ dos.writeByte(NETWORK_STATS_VERSION);
+ dos.writeFloat(s.encodeFps);
+ dos.writeInt(s.encodeQueueDepth);
+ dos.writeInt(s.droppedPFrames);
+ dos.writeByte(s.audioActive ? 1 : 0);
+ dos.writeLong(s.audioFramesSent);
+ dos.writeInt(s.suggestedBitrateKbps);
+ dos.writeInt(s.targetBitrateKbps);
+ if (NETWORK_STATS_VERSION >= 3) {
+ dos.writeByte(Math.max(0, Math.min(100, s.audioCaptureVuPercent)));
+ dos.writeLong(s.audioCaptureSilentBlocks);
+ dos.writeLong(s.audioCaptureAudibleBlocks);
+ dos.writeFloat(s.audioCapturePeakDb);
+ dos.writeFloat(s.audioCaptureRmsDb);
+ }
dos.flush();
return bos.toByteArray();
}
@@ -274,6 +298,25 @@ public final class CastProtocol {
s.recvBitrateKbps = dis.readInt();
s.congestionLevel = dis.readInt();
s.jitterMs = dis.readInt();
+ if (dis.available() > 0) {
+ byte ver = dis.readByte();
+ if (ver >= 2) {
+ s.encodeFps = dis.readFloat();
+ s.encodeQueueDepth = dis.readInt();
+ s.droppedPFrames = dis.readInt();
+ s.audioActive = dis.readByte() != 0;
+ s.audioFramesSent = dis.readLong();
+ s.suggestedBitrateKbps = dis.readInt();
+ s.targetBitrateKbps = dis.readInt();
+ if (ver >= 3 && dis.available() > 0) {
+ s.audioCaptureVuPercent = dis.readByte() & 0xff;
+ s.audioCaptureSilentBlocks = dis.readLong();
+ s.audioCaptureAudibleBlocks = dis.readLong();
+ s.audioCapturePeakDb = dis.readFloat();
+ s.audioCaptureRmsDb = dis.readFloat();
+ }
+ }
+ }
return s;
}
diff --git a/app/src/main/java/com/foxx/androidcast/network/CastTransport.java b/app/src/main/java/com/foxx/androidcast/network/CastTransport.java
index fc39519..c59354b 100644
--- a/app/src/main/java/com/foxx/androidcast/network/CastTransport.java
+++ b/app/src/main/java/com/foxx/androidcast/network/CastTransport.java
@@ -5,6 +5,8 @@ import java.io.IOException;
/** Pluggable stream transport (TCP or UDP). */
public interface CastTransport extends Closeable {
+ /** Optional proto header context; ignored when null. */
+ default void setFramingContext(CastFramingContext context) {}
/** Server: bind and wait for first peer datagram / connection. */
void listen(int port) throws IOException;
diff --git a/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java b/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java
index 4a42757..0d6383a 100644
--- a/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java
+++ b/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java
@@ -14,9 +14,10 @@ import java.net.SocketTimeoutException;
/** Reliable message transport over TCP (serialized send/receive). */
public class TcpCastTransport implements CastTransport {
private static final int CONNECT_TIMEOUT_MS = 10_000;
- private static final int HEADER_BYTES = 5; // type + int length
+ private static final int LEGACY_HEADER_BYTES = 5;
private final Object sendLock = new Object();
+ private CastFramingContext framingContext;
private final Object receiveLock = new Object();
private ServerSocket serverSocket;
@@ -77,13 +78,25 @@ public class TcpCastTransport implements CastTransport {
out = new DataOutputStream(new BufferedOutputStream(socket.getOutputStream()));
}
+ @Override
+ public void setFramingContext(CastFramingContext context) {
+ this.framingContext = context;
+ }
+
@Override
public void send(byte type, byte[] payload) throws IOException {
synchronized (sendLock) {
if (socket == null || socket.isClosed() || out == null) {
throw new IOException("Socket closed");
}
- CastProtocol.writeMessage(out, type, payload);
+ if (framingContext != null) {
+ CastProtoHeader hdr = framingContext.newHeader(CastProtoHeader.HS_DEFAULT);
+ byte[] wire = CastPacketFramer.frame(hdr, type, payload);
+ out.write(wire);
+ out.flush();
+ } else {
+ CastProtocol.writeMessage(out, type, payload);
+ }
}
}
@@ -102,6 +115,9 @@ public class TcpCastTransport implements CastTransport {
}
try {
socket.setSoTimeout(0);
+ if (framingContext != null) {
+ return readFramedMessage();
+ }
return CastProtocol.readMessage(in);
} catch (SocketTimeoutException e) {
return null;
@@ -116,10 +132,15 @@ public class TcpCastTransport implements CastTransport {
}
}
+ private CastProtocol.Message readFramedMessage() throws IOException {
+ return CastPacketFramer.readFramedMessage(bufferedIn);
+ }
+
private boolean waitForHeader(int timeoutMs) throws IOException {
+ int need = framingContext != null ? CastProtoHeader.HEADER_SIZE_V1 : LEGACY_HEADER_BYTES;
long deadline = System.currentTimeMillis() + timeoutMs;
while (System.currentTimeMillis() < deadline) {
- if (bufferedIn.available() >= HEADER_BYTES) {
+ if (bufferedIn.available() >= need) {
return true;
}
try {
@@ -129,7 +150,7 @@ public class TcpCastTransport implements CastTransport {
return false;
}
}
- return bufferedIn.available() >= HEADER_BYTES;
+ return bufferedIn.available() >= need;
}
@Override
diff --git a/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java b/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java
index 3d04722..4e36078 100644
--- a/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java
+++ b/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java
@@ -8,6 +8,7 @@ import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
+import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -15,23 +16,31 @@ import java.util.concurrent.ConcurrentHashMap;
/** Best-effort message transport over UDP with fragmentation. */
public class UdpCastTransport implements CastTransport {
private static final byte[] MAGIC = {'A', 'C', 'U', 'D'};
- private static final int HEADER = 16;
+ /** magic(4) + type(1) + msgId(4) + fragIdx(2) + fragCnt(2) + totalLen(4) */
+ private static final int HEADER = 17;
+ private static final int MAX_RELIABLE_BYTES = 8 * 1024;
private DatagramSocket socket;
private InetSocketAddress peer;
private int nextMessageId = 1;
+ private volatile boolean listening;
private final Map pending = new ConcurrentHashMap<>();
+ private CastFramingContext framingContext;
+
+ public boolean isListening() {
+ return listening && socket != null && !socket.isClosed();
+ }
@Override
public void prepareListen(int port) throws IOException {
- if (socket != null && !socket.isClosed()) {
+ if (isListening()) {
return;
}
- socket = new DatagramSocket(null);
- socket.setReuseAddress(true);
- socket.bind(new InetSocketAddress(port));
+ openSocket();
+ socket.bind(new InetSocketAddress(InetAddress.getByName("0.0.0.0"), port));
socket.setSoTimeout(500);
+ listening = true;
}
@Override
@@ -41,9 +50,25 @@ public class UdpCastTransport implements CastTransport {
@Override
public void connect(String host, int port) throws IOException {
- socket = new DatagramSocket();
+ openSocket();
peer = new InetSocketAddress(host, port);
socket.setSoTimeout(500);
+ listening = false;
+ }
+
+ private void openSocket() throws SocketException {
+ if (socket != null && !socket.isClosed()) {
+ return;
+ }
+ socket = new DatagramSocket(null);
+ socket.setReuseAddress(true);
+ socket.setReceiveBufferSize(512 * 1024);
+ socket.setSendBufferSize(512 * 1024);
+ }
+
+ @Override
+ public void setFramingContext(CastFramingContext context) {
+ this.framingContext = context;
}
@Override
@@ -51,7 +76,24 @@ public class UdpCastTransport implements CastTransport {
if (peer == null) {
throw new IOException("UDP peer not set");
}
- byte[] body = payload == null ? new byte[0] : payload;
+ byte[] body;
+ byte wireType = type;
+ if (framingContext != null) {
+ CastProtoHeader hdr = framingContext.newHeader(CastProtoHeader.HS_DEFAULT);
+ body = CastPacketFramer.frame(hdr, type, payload);
+ wireType = CastProtocol.MSG_WIRE_PACKET;
+ } else {
+ body = payload == null ? new byte[0] : payload;
+ }
+ if (body.length <= MAX_RELIABLE_BYTES) {
+ sendOnce(wireType, body);
+ sendOnce(wireType, body);
+ } else {
+ sendOnce(wireType, body);
+ }
+ }
+
+ private void sendOnce(byte type, byte[] body) throws IOException {
int messageId = nextMessageId++;
int chunkSize = CastConfig.UDP_CHUNK_SIZE - HEADER;
int fragments = Math.max(1, (body.length + chunkSize - 1) / chunkSize);
@@ -66,6 +108,9 @@ public class UdpCastTransport implements CastTransport {
@Override
public CastProtocol.Message receive(int timeoutMs) throws IOException {
+ if (socket == null || socket.isClosed()) {
+ return null;
+ }
long deadline = System.currentTimeMillis() + timeoutMs;
while (System.currentTimeMillis() < deadline) {
int remaining = (int) Math.max(1, deadline - System.currentTimeMillis());
@@ -105,13 +150,16 @@ public class UdpCastTransport implements CastTransport {
byte[] chunk = new byte[payloadLen];
System.arraycopy(data, HEADER, chunk, 0, payloadLen);
+ CastProtocol.Message raw;
if (fragCount <= 1) {
- if (totalLen != payloadLen) {
+ if (totalLen != payloadLen && totalLen > 0 && totalLen <= payloadLen) {
byte[] trimmed = new byte[totalLen];
- System.arraycopy(chunk, 0, trimmed, 0, Math.min(totalLen, payloadLen));
- return new CastProtocol.Message(type, trimmed);
+ System.arraycopy(chunk, 0, trimmed, 0, totalLen);
+ raw = new CastProtocol.Message(type, trimmed);
+ } else {
+ raw = new CastProtocol.Message(type, chunk);
}
- return new CastProtocol.Message(type, chunk);
+ return unwrapWire(raw);
}
Reassembly r = pending.get(messageId);
@@ -124,7 +172,14 @@ public class UdpCastTransport implements CastTransport {
return null;
}
pending.remove(messageId);
- return new CastProtocol.Message(type, r.assemble());
+ return unwrapWire(new CastProtocol.Message(type, r.assemble()));
+ }
+
+ private static CastProtocol.Message unwrapWire(CastProtocol.Message raw) throws IOException {
+ if (raw.type == CastProtocol.MSG_WIRE_PACKET) {
+ return CastPacketFramer.unframe(raw.payload);
+ }
+ return raw;
}
private static byte[] buildPacket(
@@ -170,15 +225,18 @@ public class UdpCastTransport implements CastTransport {
@Override
public void releaseConnection() {
peer = null;
+ pending.clear();
}
@Override
public void close() {
+ listening = false;
if (socket != null) {
socket.close();
socket = null;
}
pending.clear();
+ peer = null;
}
private static final class Reassembly {
diff --git a/app/src/main/java/com/foxx/androidcast/network/control/NetworkStatsSnapshot.java b/app/src/main/java/com/foxx/androidcast/network/control/NetworkStatsSnapshot.java
index 91052d7..74cdb70 100644
--- a/app/src/main/java/com/foxx/androidcast/network/control/NetworkStatsSnapshot.java
+++ b/app/src/main/java/com/foxx/androidcast/network/control/NetworkStatsSnapshot.java
@@ -11,6 +11,22 @@ public class NetworkStatsSnapshot {
public int congestionLevel;
public int jitterMs;
+ /** v2 extension: sender encode FPS (receiver: decode/render FPS). */
+ public float encodeFps;
+ public int encodeQueueDepth;
+ public int droppedPFrames;
+ public boolean audioActive;
+ public long audioFramesSent;
+ public int suggestedBitrateKbps;
+ public int targetBitrateKbps;
+
+ /** v3: sender pre-encode PCM levels (receiver: post-decode levels in overlay only). */
+ public int audioCaptureVuPercent;
+ public long audioCaptureSilentBlocks;
+ public long audioCaptureAudibleBlocks;
+ public float audioCapturePeakDb;
+ public float audioCaptureRmsDb;
+
public NetworkStatsSnapshot copy() {
NetworkStatsSnapshot c = new NetworkStatsSnapshot();
c.sequence = sequence;
@@ -21,6 +37,18 @@ public class NetworkStatsSnapshot {
c.recvBitrateKbps = recvBitrateKbps;
c.congestionLevel = congestionLevel;
c.jitterMs = jitterMs;
+ c.encodeFps = encodeFps;
+ c.encodeQueueDepth = encodeQueueDepth;
+ c.droppedPFrames = droppedPFrames;
+ c.audioActive = audioActive;
+ c.audioFramesSent = audioFramesSent;
+ c.suggestedBitrateKbps = suggestedBitrateKbps;
+ c.targetBitrateKbps = targetBitrateKbps;
+ c.audioCaptureVuPercent = audioCaptureVuPercent;
+ c.audioCaptureSilentBlocks = audioCaptureSilentBlocks;
+ c.audioCaptureAudibleBlocks = audioCaptureAudibleBlocks;
+ c.audioCapturePeakDb = audioCapturePeakDb;
+ c.audioCaptureRmsDb = audioCaptureRmsDb;
return c;
}
}
diff --git a/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java b/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java
index 845c7ec..8a24de6 100644
--- a/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java
+++ b/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java
@@ -17,6 +17,16 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
private long statsWindowStartMs;
private long sendWindowBytes;
private long sendWindowStartMs;
+ private StatsEnricher statsEnricher;
+
+ /** Optional hook to attach encode/decode metrics before stats are sent. */
+ public interface StatsEnricher {
+ void enrich(NetworkStatsSnapshot snapshot, long nowMs);
+ }
+
+ public void setStatsEnricher(StatsEnricher enricher) {
+ this.statsEnricher = enricher;
+ }
@Override
public NetworkControlAdvice tick(long nowMs) {
@@ -75,6 +85,9 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
s.recvBitrateKbps = estimateRecvKbps(nowMs);
s.congestionLevel = 0;
s.jitterMs = 0;
+ if (statsEnricher != null) {
+ statsEnricher.enrich(s, nowMs);
+ }
return s;
}
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java b/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java
index 66aaa04..9abe17d 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java
@@ -1,90 +1,310 @@
package com.foxx.androidcast.receiver;
+import android.content.Context;
import android.media.AudioAttributes;
import android.media.AudioFormat;
+import android.media.AudioManager;
import android.media.AudioTrack;
import android.media.MediaCodec;
import android.media.MediaFormat;
+import android.os.Handler;
+import android.os.HandlerThread;
import android.util.Log;
+import com.foxx.androidcast.media.AacAdtsHelper;
+import com.foxx.androidcast.media.PcmLevelAnalyzer;
+
import java.io.IOException;
import java.nio.ByteBuffer;
-
-/** AAC decoder → AudioTrack playback. */
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+/** AAC decoder → AudioTrack; input and output run on separate threads. */
public class AudioDecoder {
private static final String TAG = "AudioDecoder";
private static final String MIME = MediaFormat.MIMETYPE_AUDIO_AAC;
+ public interface RenderCallback {
+ void onPcmRendered(int bytes, PcmLevelAnalyzer.Levels levels);
+ }
+
+ public static final class Stats {
+ public long framesQueued;
+ public long framesFed;
+ public long pcmBytesWritten;
+ public long pcmSilentBlocks;
+ public long pcmAudibleBlocks;
+ public long decodeErrors;
+ public float lastPeak;
+ public float lastRms;
+ }
+
+ private final Context appContext;
+ private final HandlerThread workerThread = new HandlerThread("AudioDecoderCtrl");
+ private Handler worker;
+ private final LinkedBlockingQueue inputQueue = new LinkedBlockingQueue<>(128);
+ private final Stats stats = new Stats();
+
private MediaCodec codec;
private AudioTrack track;
- private boolean configured;
+ private volatile boolean configured;
+ private volatile boolean draining;
+ private Thread drainThread;
+ private int sampleRate = 44100;
+ private int channels = 2;
+ private boolean useAdtsMode;
+ private RenderCallback renderCallback;
+
+ private static final class PendingFrame {
+ final long ptsUs;
+ final byte[] data;
+
+ PendingFrame(long ptsUs, byte[] data) {
+ this.ptsUs = ptsUs;
+ this.data = data;
+ }
+ }
+
+ public AudioDecoder(Context context) {
+ this.appContext = context.getApplicationContext();
+ workerThread.start();
+ worker = new Handler(workerThread.getLooper());
+ }
+
+ public void setRenderCallback(RenderCallback callback) {
+ this.renderCallback = callback;
+ }
+
+ public Stats getStats() {
+ return stats;
+ }
public void configure(int sampleRate, int channels, byte[] csd0) throws IOException {
- MediaFormat format = MediaFormat.createAudioFormat(MIME, sampleRate, channels);
- if (csd0 != null && csd0.length > 0) {
- format.setByteBuffer("csd-0", ByteBuffer.wrap(csd0));
- }
- codec = MediaCodec.createDecoderByType(MIME);
- codec.configure(format, null, null, 0);
- codec.start();
+ final IOException[] error = new IOException[1];
+ final Object lock = new Object();
+ worker.post(() -> {
+ try {
+ releaseOnWorker();
+ AudioDecoder.this.sampleRate = sampleRate;
+ AudioDecoder.this.channels = channels;
+ useAdtsMode = csd0 == null || csd0.length < 2;
- int channelMask = channels >= 2 ? AudioFormat.CHANNEL_OUT_STEREO : AudioFormat.CHANNEL_OUT_MONO;
+ MediaFormat format = MediaFormat.createAudioFormat(MIME, sampleRate, channels);
+ if (useAdtsMode) {
+ format.setInteger(MediaFormat.KEY_IS_ADTS, 1);
+ } else {
+ format.setByteBuffer("csd-0", ByteBuffer.wrap(csd0));
+ }
+ codec = MediaCodec.createDecoderByType(MIME);
+ codec.configure(format, null, null, 0);
+ codec.start();
+ createAudioTrack(sampleRate, channels);
+ configured = true;
+ draining = true;
+ startDrainThread();
+ Log.i(TAG, "Audio ready " + sampleRate + "Hz ch=" + channels
+ + " mode=" + (useAdtsMode ? "ADTS" : "raw+csd")
+ + " csdLen=" + (csd0 != null ? csd0.length : 0));
+ } catch (IOException e) {
+ error[0] = e;
+ } catch (Exception e) {
+ error[0] = new IOException(e);
+ } finally {
+ synchronized (lock) {
+ lock.notifyAll();
+ }
+ }
+ });
+ synchronized (lock) {
+ try {
+ lock.wait(5000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ if (error[0] != null) {
+ throw error[0];
+ }
+ }
+
+ private void startDrainThread() {
+ drainThread = new Thread(this::drainLoop, "AudioDecoderDrain");
+ drainThread.start();
+ }
+
+ private void createAudioTrack(int sampleRate, int channels) {
+ int channelMask = channels >= 2
+ ? 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);
track = new AudioTrack.Builder()
.setAudioAttributes(new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
- .setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
+ .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build())
.setAudioFormat(new AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(sampleRate)
.setChannelMask(channelMask)
.build())
- .setBufferSizeInBytes(minBuf * 4)
+ .setBufferSizeInBytes(bufSize)
.setTransferMode(AudioTrack.MODE_STREAM)
.build();
+ track.setVolume(1.0f);
+ requestAudioFocus();
track.play();
- configured = true;
+ Log.i(TAG, "AudioTrack play state=" + track.getPlayState());
}
public void queueFrame(long ptsUs, byte[] data) {
- if (!configured || codec == null) {
+ if (data == null || data.length == 0) {
return;
}
- try {
- int inIndex = codec.dequeueInputBuffer(5_000);
- if (inIndex < 0) {
- return;
- }
- ByteBuffer buffer = codec.getInputBuffer(inIndex);
- if (buffer == null) {
- return;
- }
- buffer.clear();
- buffer.put(data);
- codec.queueInputBuffer(inIndex, 0, data.length, ptsUs, 0);
+ stats.framesQueued++;
+ if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
+ inputQueue.poll();
+ inputQueue.offer(new PendingFrame(ptsUs, data.clone()));
+ }
+ }
- MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
- int outIndex = codec.dequeueOutputBuffer(info, 5_000);
- while (outIndex >= 0) {
- ByteBuffer outBuf = codec.getOutputBuffer(outIndex);
- if (outBuf != null && info.size > 0 && track != null) {
- byte[] pcm = new byte[info.size];
- outBuf.position(info.offset);
- outBuf.limit(info.offset + info.size);
- outBuf.get(pcm);
- track.write(pcm, 0, pcm.length);
+ private void drainLoop() {
+ MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
+ while (draining) {
+ try {
+ PendingFrame frame = inputQueue.poll(5, TimeUnit.MILLISECONDS);
+ if (frame != null && codec != null) {
+ feedFrameSync(frame, info);
+ }
+ drainOutputSync(info);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ } catch (Exception e) {
+ stats.decodeErrors++;
+ if (draining) {
+ Log.w(TAG, "drainLoop: " + e.getMessage());
}
- codec.releaseOutputBuffer(outIndex, false);
- outIndex = codec.dequeueOutputBuffer(info, 0);
}
- } catch (Exception e) {
- Log.e(TAG, "Audio decode error", e);
+ }
+ }
+
+ private void feedFrameSync(PendingFrame frame, MediaCodec.BufferInfo info) {
+ byte[] data = frame.data;
+ if (useAdtsMode) {
+ data = AacAdtsHelper.wrapIfNeeded(data, sampleRate, channels);
+ }
+ int inIndex = codec.dequeueInputBuffer(10_000);
+ if (inIndex < 0) {
+ return;
+ }
+ ByteBuffer buffer = codec.getInputBuffer(inIndex);
+ if (buffer == null) {
+ return;
+ }
+ buffer.clear();
+ buffer.put(data);
+ codec.queueInputBuffer(inIndex, 0, data.length, frame.ptsUs, 0);
+ stats.framesFed++;
+ drainOutputSync(info);
+ }
+
+ private void drainOutputSync(MediaCodec.BufferInfo info) {
+ if (codec == null) {
+ return;
+ }
+ for (;;) {
+ int outIndex = codec.dequeueOutputBuffer(info, 0);
+ if (outIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
+ break;
+ }
+ if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
+ Log.i(TAG, "Decoder output: " + codec.getOutputFormat());
+ continue;
+ }
+ if (outIndex < 0) {
+ continue;
+ }
+ ByteBuffer outBuf = codec.getOutputBuffer(outIndex);
+ if (outBuf != null && info.size > 0 && track != null
+ && (info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0) {
+ byte[] pcm = new byte[info.size];
+ outBuf.position(info.offset);
+ outBuf.limit(info.offset + info.size);
+ outBuf.get(pcm);
+ PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(pcm, pcm.length);
+ stats.lastPeak = levels.peak;
+ stats.lastRms = levels.rms;
+ if (levels.silent) {
+ stats.pcmSilentBlocks++;
+ } else {
+ stats.pcmAudibleBlocks++;
+ }
+ int written = writePcm(pcm);
+ if (written > 0) {
+ stats.pcmBytesWritten += written;
+ if (renderCallback != null) {
+ renderCallback.onPcmRendered(written, levels);
+ }
+ }
+ }
+ codec.releaseOutputBuffer(outIndex, false);
+ }
+ }
+
+ private int writePcm(byte[] pcm) {
+ int total = 0;
+ int offset = 0;
+ while (offset < pcm.length) {
+ int w = track.write(pcm, offset, pcm.length - offset);
+ if (w <= 0) {
+ break;
+ }
+ total += w;
+ offset += w;
+ }
+ return total;
+ }
+
+ private void requestAudioFocus() {
+ AudioManager am = (AudioManager) appContext.getSystemService(Context.AUDIO_SERVICE);
+ if (am != null) {
+ am.requestAudioFocus(focusChange -> {
+ }, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN);
}
}
public void release() {
+ final Object lock = new Object();
+ worker.post(() -> {
+ releaseOnWorker();
+ synchronized (lock) {
+ lock.notifyAll();
+ }
+ });
+ synchronized (lock) {
+ try {
+ lock.wait(2000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ private void releaseOnWorker() {
+ draining = false;
configured = false;
+ inputQueue.clear();
+ if (drainThread != null) {
+ drainThread.interrupt();
+ try {
+ drainThread.join(1500);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ drainThread = null;
+ }
if (codec != null) {
try {
codec.stop();
@@ -102,4 +322,9 @@ public class AudioDecoder {
track = null;
}
}
+
+ public void releaseAndQuit() {
+ release();
+ workerThread.quitSafely();
+ }
}
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/PlaybackViewState.java b/app/src/main/java/com/foxx/androidcast/receiver/PlaybackViewState.java
new file mode 100644
index 0000000..d8268a0
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/receiver/PlaybackViewState.java
@@ -0,0 +1,21 @@
+package com.foxx.androidcast.receiver;
+
+/** Zoom/pan state for receiver diagnostics (updated from playback UI). */
+public final class PlaybackViewState {
+ private static volatile int zoomPercent;
+
+ private PlaybackViewState() {}
+
+ public static void setZoomPercent(int percent) {
+ zoomPercent = percent;
+ }
+
+ /** Deviation from 100% scale, e.g. +15 or −10. */
+ public static int getZoomPercent() {
+ return zoomPercent;
+ }
+
+ public static void reset() {
+ zoomPercent = 0;
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java
index 4df6d87..965567b 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java
@@ -93,7 +93,7 @@ public class ReceiverActivity extends AppCompatActivity {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_receiver);
PermissionHelper.request(this);
- SettingsUi.bind(this, castSettings);
+ SettingsUi.bindReceiver(this, castSettings);
statusText = findViewById(R.id.text_status);
pinInput = findViewById(R.id.edit_pin);
@@ -129,6 +129,7 @@ public class ReceiverActivity extends AppCompatActivity {
@Override
protected void onResume() {
super.onResume();
+ PermissionHelper.remindIfMissing(this, allowAudioCheck.isChecked());
updateKeepScreenOn();
}
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
index 7f55172..6b93eea 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
@@ -20,8 +20,10 @@ import com.foxx.androidcast.ICastStatusCallback;
import com.foxx.androidcast.IntentExtras;
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.control.NetworkStatsSnapshot;
+import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
import java.util.Arrays;
@@ -75,6 +77,8 @@ public class ReceiverCastService extends Service {
private String activePin = com.foxx.androidcast.CastConfig.DEFAULT_PIN;
private final StreamMetrics streamMetrics = new StreamMetrics();
private String negotiatedVideoMime;
+ private CastSettings remoteCastSettings;
+ private NetworkStatsSnapshot lastLocalStats;
private boolean streamIdle;
private long lastVideoFrameMs;
@@ -161,7 +165,15 @@ public class ReceiverCastService extends Service {
return START_STICKY;
}
+ private final Object sessionLock = new Object();
+
private void restartListening(String pin) {
+ synchronized (sessionLock) {
+ restartListeningLocked(pin);
+ }
+ }
+
+ private void restartListeningLocked(String pin) {
stopSessionOnly();
resetStreamState();
phase = Phase.LISTENING;
@@ -176,9 +188,11 @@ public class ReceiverCastService extends Service {
audioDecoder.release();
}
videoDecoder = new VideoDecoder();
- audioDecoder = new AudioDecoder();
+ audioDecoder = new AudioDecoder(this);
+ audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered(
+ pcmBytes, levels.peak, levels.rms, levels.silent));
- session = new ReceiverSession(pin, settings, new ReceiverSession.Listener() {
+ session = new ReceiverSession(this, pin, settings, new ReceiverSession.Listener() {
@Override
public void onListening() {
mainHandler.post(ReceiverCastService.this::startDiscoveryAnnouncing);
@@ -191,6 +205,11 @@ public class ReceiverCastService extends Service {
@Override
public void onAuthenticated(String senderName, CastSettings remoteSettings, String videoMime) {
+ synchronized (ReceiverCastService.this) {
+ if (remoteSettings != null && remoteSettings.isAudioRequested() && allowIncomingAudio) {
+ audioAccepted = true;
+ }
+ }
mainHandler.post(() -> onSenderConnected(senderName, remoteSettings, videoMime));
}
@@ -240,16 +259,27 @@ public class ReceiverCastService extends Service {
}
}
final boolean accepted = accept;
+ if (accepted && audioDecoder != null) {
+ try {
+ audioDecoder.configure(sampleRate, channels, csd0);
+ } catch (Exception e) {
+ Log.e(TAG, "Audio configure on config msg failed", e);
+ }
+ }
mainHandler.post(() -> onAudioConfigReceived(sampleRate, channels, csd0, accepted));
}
@Override
public void onAudioFrame(long ptsUs, byte[] data) {
final int frameBytes = data != null ? data.length : 0;
- mainHandler.post(() -> streamMetrics.onAudioPacket(frameBytes));
- if (Boolean.TRUE.equals(audioAccepted) && audioDecoder != null) {
+ final boolean accept;
+ synchronized (ReceiverCastService.this) {
+ accept = Boolean.TRUE.equals(audioAccepted);
+ }
+ if (accept && audioDecoder != null) {
audioDecoder.queueFrame(ptsUs, data);
}
+ mainHandler.post(() -> streamMetrics.onAudioPacket(frameBytes));
}
@Override
@@ -265,7 +295,7 @@ public class ReceiverCastService extends Service {
public void onPeerNetworkStats(NetworkStatsSnapshot stats) {
mainHandler.post(() -> streamMetrics.onPeerStats(stats));
}
- });
+ }, this::enrichReceiverStats);
session.start();
updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase()));
openPlaybackAwaiting(R.string.playback_listening);
@@ -275,7 +305,7 @@ public class ReceiverCastService extends Service {
private void onSenderConnected(String senderName, CastSettings remoteSettings, String videoMime) {
phase = Phase.CONNECTED;
negotiatedVideoMime = videoMime;
- audioAccepted = null;
+ remoteCastSettings = remoteSettings;
streamIdle = false;
streamMetrics.reset();
lastVideoFrameMs = 0;
@@ -305,13 +335,24 @@ public class ReceiverCastService extends Service {
private void onSenderDisconnected() {
phase = Phase.LISTENING;
audioAccepted = null;
+ remoteCastSettings = null;
streamIdle = false;
lastVideoFrameMs = 0;
- enterAwaitingMode(R.string.playback_stream_lost, true);
+ 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);
+ }
+
private void checkStreamIdle() {
if (phase != Phase.STREAMING || streamIdle || lastVideoFrameMs <= 0) {
return;
@@ -372,30 +413,19 @@ public class ReceiverCastService extends Service {
pendingAudioChannels = channels;
pendingAudioCsd = csd0;
if (accepted) {
- configureAudioIfAccepted();
updateStatus(getString(R.string.audio_playing));
} else {
if (audioDecoder != null) {
audioDecoder.release();
- audioDecoder = new AudioDecoder();
+ audioDecoder = new AudioDecoder(this);
+ audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered(
+ pcmBytes, levels.peak, levels.rms, levels.silent));
}
updateStatus(getString(R.string.audio_skipped));
}
Log.i(TAG, "Audio consent: " + accepted);
}
- private void configureAudioIfAccepted() {
- if (!Boolean.TRUE.equals(audioAccepted) || audioDecoder == null) {
- return;
- }
- try {
- audioDecoder.configure(pendingAudioRate, pendingAudioChannels, pendingAudioCsd);
- } catch (Exception e) {
- Log.e(TAG, "Audio configure failed", e);
- updateStatus("Audio: " + e.getMessage());
- }
- }
-
private void onStreamConfigReceived(int width, int height, byte[] csd0, byte[] csd1) {
com.foxx.androidcast.media.StreamDimensionResolver.Size size =
com.foxx.androidcast.media.StreamDimensionResolver.resolve(width, height, csd0);
@@ -488,6 +518,11 @@ public class ReceiverCastService extends Service {
session.stop();
session = null;
}
+ try {
+ Thread.sleep(80);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
}
private void acquireWakeLock() {
@@ -562,15 +597,41 @@ public class ReceiverCastService extends Service {
mainHandler.post(() -> streamMetrics.onRenderedFrame());
}
+ private void enrichReceiverStats(NetworkStatsSnapshot s, long nowMs) {
+ s.encodeFps = streamMetrics.getDecodeFps();
+ NetworkStatsSnapshot peer = streamMetrics.getLastPeer();
+ CastSettings tune = remoteCastSettings != null ? remoteCastSettings : settings;
+ com.foxx.androidcast.CastTuningEngine.EffectiveParams eff =
+ com.foxx.androidcast.CastTuningEngine.resolve(tune, peer);
+ s.suggestedBitrateKbps = eff.videoBitrate / 1000;
+ s.targetBitrateKbps = eff.videoBitrate / 1000;
+ if (s.recvBitrateKbps > 0 && eff.videoBitrate / 1000 > s.recvBitrateKbps * 1.15f) {
+ s.congestionLevel = Math.max(s.congestionLevel, 1);
+ }
+ float inFps = streamMetrics.getInFps();
+ float renderFps = streamMetrics.getRenderFps();
+ if (inFps > 12f && renderFps > 0 && renderFps < inFps * 0.7f) {
+ s.congestionLevel = Math.max(s.congestionLevel, 2);
+ }
+ lastLocalStats = s.copy();
+ }
+
private String buildDiagnosticsText() {
- return streamMetrics.format(
+ NetworkStatsSnapshot peer = streamMetrics.getLastPeer();
+ return CastDiagnosticsFormatter.formatHtml(
+ streamMetrics,
settings.getTransport(),
negotiatedVideoMime,
pendingWidth,
pendingHeight,
decoderWidth,
decoderHeight,
- streamIdle);
+ streamIdle,
+ settings,
+ remoteCastSettings,
+ peer,
+ lastLocalStats,
+ PlaybackViewState.getZoomPercent());
}
private void attachSurface(Surface surface) {
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java
index 68c913d..ffbc216 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java
@@ -27,6 +27,9 @@ 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.ZoomPanContainer;
+
+import androidx.core.text.HtmlCompat;
/**
* Fullscreen display: dancing-robot overlay while listening / waiting;
@@ -133,8 +136,10 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
readSizeFromIntent(getIntent());
+ ZoomPanContainer zoomContainer = findViewById(R.id.zoom_container);
aspectContainer = findViewById(R.id.aspect_container);
surfaceView = 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);
@@ -228,6 +233,7 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
private void showAwaitingOverlay(int messageResId) {
streamRendering = false;
+ PlaybackViewState.reset();
if (awaitingOverlay == null) {
return;
}
@@ -268,7 +274,7 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
try {
String text = receiverService.getDiagnosticsText();
if (text != null) {
- diagnosticsText.setText(text);
+ diagnosticsText.setText(HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY));
}
} catch (RemoteException ignored) {
}
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java
index 3b2cfb9..837a313 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java
@@ -2,8 +2,13 @@ package com.foxx.androidcast.receiver;
import android.util.Log;
+import android.content.Context;
+
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastSettings;
+import com.foxx.androidcast.DeviceInfo;
+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.CastTransport;
@@ -44,6 +49,7 @@ public class ReceiverSession {
void onPeerNetworkStats(NetworkStatsSnapshot stats);
}
+ private final Context appContext;
private final String pin;
private final CastSettings localSettings;
private final Listener listener;
@@ -53,14 +59,25 @@ public class ReceiverSession {
private CastSession session;
private CastTransport listenTransport;
private NetworkFeedbackManager networkFeedback;
+ private PassThroughNetworkControlPlane.StatsEnricher statsEnricher;
- public ReceiverSession(String pin, CastSettings localSettings, Listener listener) {
+ public ReceiverSession(Context context, String pin, CastSettings localSettings, Listener listener) {
+ this(context, pin, localSettings, listener, null);
+ }
+
+ public ReceiverSession(Context context, String pin, CastSettings localSettings, Listener listener,
+ PassThroughNetworkControlPlane.StatsEnricher statsEnricher) {
+ this.appContext = context.getApplicationContext();
this.pin = pin;
this.localSettings = localSettings;
this.listener = listener;
+ this.statsEnricher = statsEnricher;
}
public void start() {
+ if (running.get()) {
+ stop();
+ }
if (!running.compareAndSet(false, true)) {
return;
}
@@ -95,6 +112,11 @@ public class ReceiverSession {
try {
if (listenTransport == null) {
listenTransport = CastTransportFactory.create(localSettings);
+ listenTransport.setFramingContext(new CastFramingContext(
+ 0,
+ DeviceInfo.getPlaceholderId(appContext),
+ CastCodecFlags.localDecoderVideoMask(),
+ CastCodecFlags.localAudioMask()));
session = new CastSession(listenTransport);
session.prepareListen(CastConfig.CAST_PORT);
listener.onListening();
@@ -104,6 +126,10 @@ public class ReceiverSession {
session = new CastSession(listenTransport);
}
networkFeedback = new NetworkFeedbackManager(session, false);
+ if (networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) {
+ ((PassThroughNetworkControlPlane) networkFeedback.getPlane())
+ .setStatsEnricher(statsEnricher);
+ }
session.acceptClient();
CastSession.HandshakeResult hs = session.serverHandshake(pin, localSettings);
listener.onAuthenticated(hs.senderName, hs.settings, hs.negotiatedVideoMime);
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/StreamMetrics.java b/app/src/main/java/com/foxx/androidcast/receiver/StreamMetrics.java
index ee49085..c62727a 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/StreamMetrics.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/StreamMetrics.java
@@ -6,6 +6,12 @@ import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
public final class StreamMetrics {
private long videoPackets;
private long audioPackets;
+ private long audioRenderedWrites;
+ private long audioPcmSilentBlocks;
+ private long audioPcmAudibleBlocks;
+ private float audioVuSmoothed;
+ private float audioLastPeakDb;
+ private float audioLastRmsDb;
private long keyFrames;
private long deltaFrames;
private long videoBytes;
@@ -21,10 +27,17 @@ public final class StreamMetrics {
private int peerRttMs;
private int peerRecvKbps;
private int peerSendKbps;
+ private NetworkStatsSnapshot lastPeer;
public synchronized void reset() {
videoPackets = 0;
audioPackets = 0;
+ audioRenderedWrites = 0;
+ audioPcmSilentBlocks = 0;
+ audioPcmAudibleBlocks = 0;
+ audioVuSmoothed = 0f;
+ audioLastPeakDb = -96f;
+ audioLastRmsDb = -96f;
keyFrames = 0;
deltaFrames = 0;
videoBytes = 0;
@@ -40,6 +53,7 @@ public final class StreamMetrics {
peerRttMs = 0;
peerRecvKbps = 0;
peerSendKbps = 0;
+ lastPeer = null;
}
public synchronized void onVideoPacket(boolean keyFrame, int bytes) {
@@ -74,6 +88,51 @@ public final class StreamMetrics {
audioBytes += bytes;
}
+ public synchronized void onAudioRendered(int pcmBytes, float peak, float rms, boolean silent) {
+ if (pcmBytes > 0) {
+ audioRenderedWrites++;
+ }
+ if (silent) {
+ audioPcmSilentBlocks++;
+ } else {
+ audioPcmAudibleBlocks++;
+ }
+ float instant = Math.max(peak, rms);
+ audioVuSmoothed = instant > audioVuSmoothed
+ ? instant * 0.35f + audioVuSmoothed * 0.65f
+ : instant * 0.12f + audioVuSmoothed * 0.88f;
+ if (peak > 1e-9f) {
+ audioLastPeakDb = (float) (20.0 * Math.log10(peak));
+ }
+ if (rms > 1e-9f) {
+ audioLastRmsDb = (float) (20.0 * Math.log10(rms));
+ }
+ }
+
+ public synchronized long getAudioRenderedWrites() {
+ return audioRenderedWrites;
+ }
+
+ public synchronized long getAudioPcmSilentBlocks() {
+ return audioPcmSilentBlocks;
+ }
+
+ public synchronized long getAudioPcmAudibleBlocks() {
+ return audioPcmAudibleBlocks;
+ }
+
+ public synchronized int getAudioVuPercent() {
+ return com.foxx.androidcast.media.PcmLevelAnalyzer.vuPercent(audioVuSmoothed);
+ }
+
+ public synchronized float getAudioLastPeakDb() {
+ return audioLastPeakDb;
+ }
+
+ public synchronized float getAudioLastRmsDb() {
+ return audioLastRmsDb;
+ }
+
public synchronized void onRenderedFrame() {
renderedFrames++;
lastRenderedFrameMs = System.currentTimeMillis();
@@ -83,72 +142,89 @@ public final class StreamMetrics {
if (peer == null) {
return;
}
+ lastPeer = peer;
peerRttMs = peer.rttMs;
peerRecvKbps = peer.recvBitrateKbps;
peerSendKbps = peer.sendBitrateKbps;
}
+ public synchronized NetworkStatsSnapshot getLastPeer() {
+ return lastPeer;
+ }
+
public synchronized long getLastVideoPacketMs() {
return lastVideoPacketMs;
}
- public synchronized String format(String transport, String videoCodec, int streamW, int streamH, int renderW,
- int renderH, boolean idle) {
+ public synchronized float getInFps() {
long now = System.currentTimeMillis();
float inFps = lastSecondInFps > 0 ? lastSecondInFps : computeFps(videoPackets, firstVideoPacketMs, now);
- float renderFps = computeFps(renderedFrames, firstVideoPacketMs, lastRenderedFrameMs);
- int localKbps = computeLocalKbps(now);
- long idleMs = lastVideoPacketMs > 0 ? now - lastVideoPacketMs : -1;
+ return inFps;
+ }
+ public synchronized float getRenderFps() {
+ long now = System.currentTimeMillis();
+ return computeFps(renderedFrames, firstVideoPacketMs, lastRenderedFrameMs > 0 ? lastRenderedFrameMs : now);
+ }
+
+ public synchronized int getBwInKbps() {
+ return computeLocalKbps(System.currentTimeMillis());
+ }
+
+ public synchronized long getVideoPackets() {
+ return videoPackets;
+ }
+
+ public synchronized long getAudioPackets() {
+ return audioPackets;
+ }
+
+ public synchronized long getKeyFrames() {
+ return keyFrames;
+ }
+
+ public synchronized long getDeltaFrames() {
+ return deltaFrames;
+ }
+
+ public synchronized int getAvgKbPerFrame() {
+ if (videoPackets <= 0) {
+ return 0;
+ }
+ return (int) (videoBytes / videoPackets / 1024);
+ }
+
+ public synchronized long getLastFrameIdleMs() {
+ if (lastVideoPacketMs <= 0) {
+ return -1;
+ }
+ return System.currentTimeMillis() - lastVideoPacketMs;
+ }
+
+ public synchronized int getPeerRttMs() {
+ return peerRttMs;
+ }
+
+ public synchronized int getPeerSendKbps() {
+ return peerSendKbps;
+ }
+
+ public synchronized int getPeerRecvKbps() {
+ return peerRecvKbps;
+ }
+
+ public synchronized float getDecodeFps() {
+ return getRenderFps();
+ }
+
+ /** Plain-text fallback (tests / logging). */
+ public synchronized String format(String transport, String videoCodec, int streamW, int streamH, int renderW,
+ int renderH, boolean idle) {
StringBuilder sb = new StringBuilder();
- sb.append("Transport: ").append(transport != null ? transport.toUpperCase() : "?").append('\n');
- if (videoCodec != null && !videoCodec.isEmpty()) {
- sb.append("Codec: ").append(
- com.foxx.androidcast.media.CodecNegotiator.displayName(videoCodec)).append('\n');
- }
- sb.append("Stream: ").append(streamW).append('x').append(streamH);
- if (renderW > 0 && renderH > 0 && (renderW != streamW || renderH != streamH)) {
- sb.append(" render: ").append(renderW).append('x').append(renderH);
- }
- sb.append('\n');
- sb.append("In FPS: ").append(formatFloat(inFps));
- sb.append(" Render FPS: ").append(formatFloat(renderFps)).append('\n');
- sb.append("BW in (est): ").append(localKbps > 0 ? localKbps + " kbps" : "n/a");
- sb.append(" RTT: ").append(peerRttMs > 0 ? peerRttMs + " ms" : "n/a").append('\n');
- if (peerRecvKbps > 0 || peerSendKbps > 0) {
- sb.append("Sender (peer): tx ").append(peerSendKbps).append(" / rx ")
- .append(peerRecvKbps).append(" kbps\n");
- }
- sb.append("Video pkts: ").append(videoPackets);
- sb.append(" (IDR ").append(keyFrames).append(" / P ").append(deltaFrames).append(')');
- if (videoPackets > 0) {
- int avgKb = (int) (videoBytes / videoPackets / 1024);
- sb.append(" ~").append(Math.max(1, avgKb)).append(" KB/frame");
- }
- sb.append('\n');
- if (inFps > 0 && inFps < 8f) {
- sb.append("Hint: low In FPS → sender encode stall / tiny frames / TCP backlog\n");
- }
- if (localKbps > 0 && localKbps < 350 && inFps < 8f && videoPackets > 30) {
- sb.append("Hint: very low BW — static screen or throttled encoder\n");
- }
- if (peerRttMs > 450 && inFps < 15f) {
- sb.append("Hint: high RTT ").append(peerRttMs).append(" ms — Wi‑Fi congestion\n");
- }
- if (renderFps > 0 && inFps > renderFps + 3f) {
- sb.append("Hint: decode slower than network\n");
- }
- sb.append("Audio pkts: ").append(audioPackets);
- if (audioPackets == 0 && videoPackets > 20) {
- sb.append(" (play media on sender for capture audio)");
- }
- sb.append('\n');
- sb.append("Video bytes: ").append(formatBytes(videoBytes));
- sb.append(" Audio bytes: ").append(formatBytes(audioBytes)).append('\n');
- sb.append("State: ").append(idle ? "idle (awaiting)" : "streaming");
- if (idleMs >= 0) {
- sb.append(" last frame: ").append(idleMs).append(" ms ago");
- }
+ sb.append("In FPS: ").append(String.format("%.1f", getInFps()));
+ sb.append(" Render: ").append(String.format("%.1f", getRenderFps()));
+ sb.append(" BW: ").append(getBwInKbps()).append(" kbps");
+ sb.append(idle ? " idle" : " live");
return sb.toString();
}
@@ -179,21 +255,4 @@ public final class StreamMetrics {
}
return count / sec;
}
-
- private static String formatFloat(float v) {
- if (v <= 0f) {
- return "n/a";
- }
- return String.format(java.util.Locale.US, "%.1f", v);
- }
-
- private static String formatBytes(long bytes) {
- if (bytes < 1024) {
- return bytes + " B";
- }
- if (bytes < 1024 * 1024) {
- return String.format(java.util.Locale.US, "%.1f KB", bytes / 1024f);
- }
- return String.format(java.util.Locale.US, "%.1f MB", bytes / (1024f * 1024f));
- }
}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java b/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java
index 940efa6..6c07075 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java
@@ -1,32 +1,94 @@
package com.foxx.androidcast.sender;
+import android.Manifest;
+import android.content.Context;
+import android.content.pm.PackageManager;
import android.media.AudioAttributes;
import android.media.AudioFormat;
import android.media.AudioPlaybackCaptureConfiguration;
import android.media.AudioRecord;
+import android.media.MediaRecorder;
import android.media.projection.MediaProjection;
import android.os.Build;
import android.util.Log;
-import com.foxx.androidcast.CastConfig;
+import androidx.core.content.ContextCompat;
-/** Captures device playback audio via MediaProjection (API 29+). */
+import com.foxx.androidcast.CastConfig;
+import com.foxx.androidcast.media.PcmLevelAnalyzer;
+
+/**
+ * Captures device playback via MediaProjection (primary) and optionally mixes microphone
+ * when playback is silent. Playback is never replaced by mic-only capture.
+ */
public class AudioCapture {
private static final String TAG = "AudioCapture";
public interface Callback {
- void onPcm(byte[] pcm, int length, long ptsUs);
+ void onPcm(byte[] pcm, int length, long ptsUs, PcmLevelAnalyzer.Levels levels);
+
+ void onCaptureModeChanged(String mode);
}
- private AudioRecord record;
+ private final Context appContext;
+ private AudioRecord playbackRecord;
+ private AudioRecord micRecord;
private Thread thread;
private volatile boolean running;
+ private volatile String captureMode = "playback";
+
+ public AudioCapture(Context context) {
+ this.appContext = context.getApplicationContext();
+ }
+
+ public String getCaptureMode() {
+ return captureMode;
+ }
public void start(MediaProjection projection, Callback callback) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
Log.w(TAG, "Audio capture requires API 29+");
return;
}
+ if (!startPlayback(projection)) {
+ Log.e(TAG, "Playback capture unavailable");
+ return;
+ }
+ micRecord = canUseMic() ? buildMicRecord() : null;
+ if (micRecord != null && micRecord.getState() == AudioRecord.STATE_INITIALIZED) {
+ micRecord.startRecording();
+ Log.i(TAG, "Mic mix enabled (playback primary)");
+ } else {
+ micRecord = null;
+ }
+ running = true;
+ playbackRecord.startRecording();
+ captureMode = micRecord != null ? "playback+mic" : "playback";
+ if (callback != null) {
+ callback.onCaptureModeChanged(captureMode);
+ }
+ Log.i(TAG, "Audio capture started: " + captureMode);
+ thread = new Thread(() -> mixLoop(callback), "AudioCapture");
+ thread.start();
+ }
+
+ private boolean startPlayback(MediaProjection projection) {
+ try {
+ playbackRecord = buildPlaybackRecord(projection);
+ if (playbackRecord.getState() != AudioRecord.STATE_INITIALIZED) {
+ Log.e(TAG, "Playback AudioRecord not initialized");
+ releasePlayback();
+ return false;
+ }
+ return true;
+ } catch (Exception e) {
+ Log.e(TAG, "Playback capture setup failed", e);
+ releasePlayback();
+ return false;
+ }
+ }
+
+ private AudioRecord buildPlaybackRecord(MediaProjection projection) {
int channelMask = AudioFormat.CHANNEL_IN_STEREO;
int encoding = AudioFormat.ENCODING_PCM_16BIT;
int minBuf = AudioRecord.getMinBufferSize(CastConfig.AUDIO_SAMPLE_RATE, channelMask, encoding);
@@ -38,46 +100,98 @@ public class AudioCapture {
.setChannelMask(channelMask)
.build();
- AudioPlaybackCaptureConfiguration capConfig = new AudioPlaybackCaptureConfiguration.Builder(projection)
- .addMatchingUsage(AudioAttributes.USAGE_MEDIA)
- .addMatchingUsage(AudioAttributes.USAGE_GAME)
- .addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
- .addMatchingUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
- .addMatchingUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY)
- .build();
+ AudioPlaybackCaptureConfiguration.Builder capBuilder =
+ new AudioPlaybackCaptureConfiguration.Builder(projection);
+ capBuilder.addMatchingUsage(AudioAttributes.USAGE_MEDIA);
+ capBuilder.addMatchingUsage(AudioAttributes.USAGE_GAME);
+ capBuilder.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN);
+ capBuilder.addMatchingUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION);
+ capBuilder.addMatchingUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY);
+ capBuilder.addMatchingUsage(AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE);
+ capBuilder.addMatchingUsage(AudioAttributes.USAGE_NOTIFICATION);
+ capBuilder.addMatchingUsage(AudioAttributes.USAGE_ALARM);
+ capBuilder.addMatchingUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION);
- record = new AudioRecord.Builder()
+ return new AudioRecord.Builder()
.setAudioFormat(format)
.setBufferSizeInBytes(bufferSize)
- .setAudioPlaybackCaptureConfig(capConfig)
+ .setAudioPlaybackCaptureConfig(capBuilder.build())
.build();
-
- if (record.getState() != AudioRecord.STATE_INITIALIZED) {
- Log.e(TAG, "AudioRecord not initialized");
- return;
- }
-
- running = true;
- record.startRecording();
- Log.i(TAG, "Playback audio capture started");
- thread = new Thread(() -> readLoop(callback), "AudioCapture");
- thread.start();
}
- private void readLoop(Callback callback) {
- byte[] buf = new byte[4096];
+ private AudioRecord buildMicRecord() {
+ int channelMask = AudioFormat.CHANNEL_IN_STEREO;
+ int encoding = AudioFormat.ENCODING_PCM_16BIT;
+ int minBuf = AudioRecord.getMinBufferSize(CastConfig.AUDIO_SAMPLE_RATE, channelMask, encoding);
+ int bufferSize = Math.max(minBuf, 8192) * 2;
+ return new AudioRecord(
+ MediaRecorder.AudioSource.VOICE_RECOGNITION,
+ CastConfig.AUDIO_SAMPLE_RATE,
+ channelMask,
+ encoding,
+ bufferSize);
+ }
+
+ private boolean canUseMic() {
+ return ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO)
+ == PackageManager.PERMISSION_GRANTED;
+ }
+
+ private void mixLoop(Callback callback) {
+ byte[] playBuf = new byte[4096];
+ byte[] micBuf = new byte[4096];
long startNs = System.nanoTime();
while (running) {
- int read = record.read(buf, 0, buf.length);
- if (read > 0) {
- long ptsUs = (System.nanoTime() - startNs) / 1000;
- callback.onPcm(buf, read, ptsUs);
- } else if (read < 0) {
- Log.w(TAG, "Audio read error: " + read);
+ 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");
+ 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);
+ }
+ } 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;
+ if (callback != null) {
+ PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(out, outLen);
+ callback.onPcm(out, outLen, ptsUs, levels);
}
}
}
+ private static void notifyMode(Callback callback, String mode) {
+ if (callback != null) {
+ callback.onCaptureModeChanged(mode);
+ }
+ }
+
+ private static boolean hasAudibleSamples(byte[] buf, int len) {
+ for (int i = 0; i + 1 < len; i += 2) {
+ short s = (short) ((buf[i] & 0xff) | (buf[i + 1] << 8));
+ if (Math.abs(s) > 256) {
+ return true;
+ }
+ }
+ return false;
+ }
+
public void stop() {
running = false;
if (thread != null) {
@@ -86,14 +200,31 @@ public class AudioCapture {
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
+ thread = null;
}
- if (record != null) {
+ releaseMic();
+ releasePlayback();
+ }
+
+ private void releasePlayback() {
+ if (playbackRecord != null) {
try {
- record.stop();
+ playbackRecord.stop();
} catch (Exception ignored) {
}
- record.release();
- record = null;
+ playbackRecord.release();
+ playbackRecord = null;
+ }
+ }
+
+ private void releaseMic() {
+ if (micRecord != null) {
+ try {
+ micRecord.stop();
+ } catch (Exception ignored) {
+ }
+ micRecord.release();
+ micRecord = null;
}
}
}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java b/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java
index c629341..dc13ed9 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java
@@ -40,6 +40,26 @@ public class AudioEncoder {
running = true;
drainThread = new Thread(this::drainLoop, "AudioEncoderDrain");
drainThread.start();
+ startSilencePrimer();
+ }
+
+ /** Feeds silent PCM so AAC config is emitted even when playback capture is idle. */
+ private void startSilencePrimer() {
+ Thread primer = new Thread(() -> {
+ int frameBytes = CastConfig.AUDIO_SAMPLE_RATE * 2 * 2 / 50;
+ byte[] silence = new byte[frameBytes];
+ for (int i = 0; i < 40 && running; i++) {
+ queuePcm(silence, silence.length, i * 20_000L);
+ try {
+ Thread.sleep(20);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
+ }, "AudioSilencePrimer");
+ primer.setDaemon(true);
+ primer.start();
}
public void queuePcm(byte[] pcm, int length, long ptsUs) {
diff --git a/app/src/main/java/com/foxx/androidcast/sender/CastFanoutPump.java b/app/src/main/java/com/foxx/androidcast/sender/CastFanoutPump.java
new file mode 100644
index 0000000..288fca9
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/sender/CastFanoutPump.java
@@ -0,0 +1,130 @@
+package com.foxx.androidcast.sender;
+
+import android.util.Log;
+
+import com.foxx.androidcast.network.CastProtocol;
+import com.foxx.androidcast.network.CastSession;
+import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/** Fan-out send queue to multiple {@link CastSession}s (1:N cast). */
+public final class CastFanoutPump {
+ private static final String TAG = "CastFanoutPump";
+ private static final int MAX_QUEUE = 48;
+
+ private final LinkedBlockingQueue queue = new LinkedBlockingQueue<>(MAX_QUEUE);
+ private final AtomicBoolean running = new AtomicBoolean(false);
+ private final List sessions = new ArrayList<>();
+ private Thread thread;
+ private Runnable onSendFailed;
+ private PassThroughNetworkControlPlane statsPlane;
+ private volatile int droppedPFrames;
+
+ public void setSessions(List list) {
+ sessions.clear();
+ if (list != null) {
+ sessions.addAll(list);
+ }
+ }
+
+ public int getDroppedPFrames() {
+ return droppedPFrames;
+ }
+
+ public int getQueueDepth() {
+ return queue.size();
+ }
+
+ public void start(List sessionList, Runnable onSendFailed,
+ PassThroughNetworkControlPlane statsPlane) {
+ setSessions(sessionList);
+ this.onSendFailed = onSendFailed;
+ this.statsPlane = statsPlane;
+ if (!running.compareAndSet(false, true)) {
+ return;
+ }
+ thread = new Thread(this::run, "CastFanoutPump");
+ thread.start();
+ }
+
+ public void stop() {
+ running.set(false);
+ if (thread != null) {
+ thread.interrupt();
+ try {
+ thread.join(1500);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ thread = null;
+ }
+ queue.clear();
+ sessions.clear();
+ }
+
+ public void enqueueVideo(long ptsUs, boolean keyFrame, byte[] annexBReadyPayload) {
+ if (!running.get()) {
+ return;
+ }
+ CastSendPump.Outgoing item = CastSendPump.Outgoing.video(ptsUs, keyFrame, annexBReadyPayload);
+ if (!queue.offer(item)) {
+ if (!keyFrame) {
+ CastSendPump.Outgoing dropped = queue.poll();
+ if (dropped != null && !dropped.keyFrame) {
+ droppedPFrames++;
+ }
+ }
+ queue.offer(item);
+ }
+ }
+
+ public void enqueue(byte type, byte[] payload) {
+ if (!running.get()) {
+ return;
+ }
+ CastSendPump.Outgoing item = CastSendPump.Outgoing.raw(type, payload);
+ while (running.get() && !queue.offer(item)) {
+ queue.poll();
+ }
+ }
+
+ private void run() {
+ while (running.get()) {
+ try {
+ CastSendPump.Outgoing item = queue.poll(200, TimeUnit.MILLISECONDS);
+ if (item == null || sessions.isEmpty()) {
+ continue;
+ }
+ byte[] payload = item.buildPayload();
+ for (CastSession session : sessions) {
+ if (session == null) {
+ continue;
+ }
+ try {
+ session.send(item.type, payload);
+ if (statsPlane != null) {
+ statsPlane.recordSendBytes(payload.length + 5);
+ }
+ } catch (IOException e) {
+ Log.w(TAG, "Send to peer failed: " + e.getMessage());
+ }
+ }
+ } catch (InterruptedException e) {
+ if (!running.get()) {
+ break;
+ }
+ } catch (IOException e) {
+ Log.w(TAG, "Payload build failed: " + e.getMessage());
+ if (onSendFailed != null) {
+ onSendFailed.run();
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/CastReceiverTarget.java b/app/src/main/java/com/foxx/androidcast/sender/CastReceiverTarget.java
new file mode 100644
index 0000000..8be863d
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/sender/CastReceiverTarget.java
@@ -0,0 +1,29 @@
+package com.foxx.androidcast.sender;
+
+import java.io.Serializable;
+
+/** One cast destination (host, port, capabilities from discovery). */
+public class CastReceiverTarget implements Serializable {
+ public final String name;
+ public final String host;
+ public final int port;
+ public final String transport;
+ public final int videoCodecs;
+ public final int audioCodecs;
+ public final int placeholderId;
+
+ public CastReceiverTarget(String name, String host, int port, String transport,
+ int videoCodecs, int audioCodecs, int placeholderId) {
+ this.name = name;
+ this.host = host;
+ this.port = port;
+ this.transport = transport;
+ this.videoCodecs = videoCodecs;
+ this.audioCodecs = audioCodecs;
+ this.placeholderId = placeholderId;
+ }
+
+ public String key() {
+ return host + ":" + port;
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java b/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java
index 05675d5..8c4ee0d 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java
@@ -117,28 +117,28 @@ public final class CastSendPump {
}
}
- private static final class Outgoing {
+ static final class Outgoing {
final byte type;
final byte[] payload;
final long ptsUs;
final boolean keyFrame;
- private Outgoing(byte type, byte[] payload, long ptsUs, boolean keyFrame) {
+ Outgoing(byte type, byte[] payload, long ptsUs, boolean keyFrame) {
this.type = type;
this.payload = payload;
this.ptsUs = ptsUs;
this.keyFrame = keyFrame;
}
- static Outgoing raw(byte type, byte[] payload) {
+ public static Outgoing raw(byte type, byte[] payload) {
return new Outgoing(type, payload, 0, false);
}
- static Outgoing video(long ptsUs, boolean keyFrame, byte[] payload) {
+ public static Outgoing video(long ptsUs, boolean keyFrame, byte[] payload) {
return new Outgoing(CastProtocol.MSG_VIDEO_FRAME, payload, ptsUs, keyFrame);
}
- byte[] buildPayload() throws IOException {
+ public byte[] buildPayload() throws IOException {
if (type != CastProtocol.MSG_VIDEO_FRAME) {
return payload;
}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java b/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java
index 14e2243..21f894d 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java
@@ -12,79 +12,108 @@ import com.foxx.androidcast.R;
import com.foxx.androidcast.discovery.DiscoveryManager;
import java.util.ArrayList;
+import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Set;
-/** Device list with clear selection highlight. */
+/** Device list with single or multi selection. */
public class DeviceListAdapter extends BaseAdapter {
- private final LayoutInflater inflater;
- private final List devices = new ArrayList<>();
- private int selectedPosition = -1;
+ private final LayoutInflater inflater;
+ private final boolean multiSelect;
+ private final List devices = new ArrayList<>();
+ private int selectedPosition = -1;
+ private final Set selectedPositions = new LinkedHashSet<>();
- public DeviceListAdapter(Context context) {
- inflater = LayoutInflater.from(context);
+ public DeviceListAdapter(Context context, boolean multiSelect) {
+ inflater = LayoutInflater.from(context);
+ this.multiSelect = multiSelect;
+ }
+
+ public void clearDevices() {
+ devices.clear();
+ selectedPosition = -1;
+ selectedPositions.clear();
+ notifyDataSetChanged();
+ }
+
+ public void setDevices(List list) {
+ devices.clear();
+ if (list != null) {
+ devices.addAll(list);
}
-
- public void clearDevices() {
- devices.clear();
- selectedPosition = -1;
- notifyDataSetChanged();
+ if (selectedPosition >= devices.size()) {
+ selectedPosition = -1;
}
+ selectedPositions.removeIf(i -> i >= devices.size());
+ notifyDataSetChanged();
+ }
- public void setDevices(List list) {
- devices.clear();
- if (list != null) {
- devices.addAll(list);
+ public void setSelectedPosition(int position) {
+ if (multiSelect) {
+ if (position >= 0 && position < devices.size()) {
+ if (selectedPositions.contains(position)) {
+ selectedPositions.remove(position);
+ } else {
+ selectedPositions.add(position);
}
- if (selectedPosition >= devices.size()) {
- selectedPosition = -1;
+ }
+ } else {
+ selectedPosition = position;
+ }
+ notifyDataSetChanged();
+ }
+
+ public int getSelectedPosition() {
+ return selectedPosition;
+ }
+
+ public List getSelectedDevices() {
+ List out = new ArrayList<>();
+ if (multiSelect) {
+ for (int i : selectedPositions) {
+ if (i >= 0 && i < devices.size()) {
+ out.add(devices.get(i));
}
- notifyDataSetChanged();
+ }
+ } else if (selectedPosition >= 0 && selectedPosition < devices.size()) {
+ out.add(devices.get(selectedPosition));
}
+ return out;
+ }
- public void setSelectedPosition(int position) {
- selectedPosition = position;
- notifyDataSetChanged();
- }
+ public DiscoveryManager.DiscoveredDevice getSelectedDevice() {
+ List list = getSelectedDevices();
+ return list.isEmpty() ? null : list.get(0);
+ }
- public int getSelectedPosition() {
- return selectedPosition;
- }
+ @Override
+ public int getCount() {
+ return devices.size();
+ }
- public DiscoveryManager.DiscoveredDevice getSelectedDevice() {
- if (selectedPosition < 0 || selectedPosition >= devices.size()) {
- return null;
- }
- return devices.get(selectedPosition);
- }
+ @Override
+ public DiscoveryManager.DiscoveredDevice getItem(int position) {
+ return devices.get(position);
+ }
- @Override
- public int getCount() {
- return devices.size();
- }
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
- @Override
- public DiscoveryManager.DiscoveredDevice getItem(int position) {
- return devices.get(position);
- }
-
- @Override
- public long getItemId(int position) {
- return position;
- }
-
- @Override
- public View getView(int position, View convertView, ViewGroup parent) {
- View row = convertView;
- if (row == null) {
- row = inflater.inflate(R.layout.item_device, parent, false);
- }
- TextView label = row.findViewById(R.id.device_label);
- DiscoveryManager.DiscoveredDevice d = devices.get(position);
- label.setText(d.name + " — " + d.host + " [" + d.transport.toUpperCase() + "]");
- boolean selected = position == selectedPosition;
- row.setActivated(selected);
- row.setSelected(selected);
- label.setTypeface(null, selected ? Typeface.BOLD : Typeface.NORMAL);
- return row;
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ View row = convertView;
+ if (row == null) {
+ row = inflater.inflate(R.layout.item_device, parent, false);
}
+ TextView label = row.findViewById(R.id.device_label);
+ DiscoveryManager.DiscoveredDevice d = devices.get(position);
+ label.setText(d.name + " — " + d.host + " [" + d.transport.toUpperCase() + "]");
+ boolean selected = multiSelect ? selectedPositions.contains(position) : position == selectedPosition;
+ row.setActivated(selected);
+ row.setSelected(selected);
+ label.setTypeface(null, selected ? Typeface.BOLD : Typeface.NORMAL);
+ return row;
+ }
}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java b/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java
new file mode 100644
index 0000000..b28b997
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java
@@ -0,0 +1,201 @@
+package com.foxx.androidcast.sender;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.foxx.androidcast.CastSettings;
+import com.foxx.androidcast.DeviceInfo;
+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.CastTransportFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+/**
+ * Manages multiple receiver sessions (connect, handshake, fan-out send).
+ * Requests keyframes when a peer joins or changes transport.
+ */
+public final class MultiCastCoordinator {
+ private static final String TAG = "MultiCastCoordinator";
+ private static final int CONNECT_RETRIES = 20;
+
+ public interface KeyframeRequester {
+ void requestKeyframe();
+ }
+
+ public static final class ActivePeer {
+ public final CastReceiverTarget target;
+ public final CastSession session;
+ public CastSettings settings;
+ public String negotiatedMime;
+
+ ActivePeer(CastReceiverTarget target, CastSession session, CastSettings settings, String mime) {
+ this.target = target;
+ this.session = session;
+ this.settings = settings;
+ this.negotiatedMime = mime;
+ }
+ }
+
+ private final Context appContext;
+ private final int sessionId;
+ private final CastFramingContext framingContext;
+ private final CopyOnWriteArrayList peers = new CopyOnWriteArrayList<>();
+ private KeyframeRequester keyframeRequester;
+
+ public MultiCastCoordinator(Context context) {
+ appContext = context.getApplicationContext();
+ sessionId = new Random().nextInt();
+ int placeholderId = DeviceInfo.getPlaceholderId(appContext);
+ framingContext = new CastFramingContext(
+ sessionId,
+ placeholderId,
+ CastCodecFlags.localVideoMask(),
+ CastCodecFlags.localAudioMask());
+ }
+
+ public int getSessionId() {
+ return sessionId;
+ }
+
+ public CastFramingContext getFramingContext() {
+ return framingContext;
+ }
+
+ public void setKeyframeRequester(KeyframeRequester requester) {
+ this.keyframeRequester = requester;
+ }
+
+ public List getPeers() {
+ return new ArrayList<>(peers);
+ }
+
+ public List getSessions() {
+ List out = new ArrayList<>();
+ for (ActivePeer p : peers) {
+ out.add(p.session);
+ }
+ return out;
+ }
+
+ public void connectAndHandshake(List targets, CastSettings baseSettings,
+ String deviceName, String pin) throws IOException {
+ peers.clear();
+ IOException last = null;
+ for (CastReceiverTarget t : targets) {
+ CastSettings peerSettings = cloneSettings(baseSettings);
+ peerSettings.setTransport(t.transport);
+ CastSession session = null;
+ try {
+ session = createSession(peerSettings);
+ connectWithRetry(session, t.host, t.port);
+ CastSession.HandshakeResult hs = session.clientHandshake(deviceName, pin, peerSettings);
+ peers.add(new ActivePeer(t, session, hs.settings, hs.negotiatedVideoMime));
+ Log.i(TAG, "Peer ready " + t.name + " " + hs.negotiatedVideoMime);
+ } catch (IOException e) {
+ last = e;
+ Log.w(TAG, "Peer failed " + t.name + ": " + e.getMessage());
+ if (session != null) {
+ session.close();
+ }
+ }
+ }
+ if (peers.isEmpty() && last != null) {
+ throw last;
+ }
+ if (keyframeRequester != null) {
+ keyframeRequester.requestKeyframe();
+ }
+ }
+
+ public void addPeer(CastReceiverTarget target, CastSettings baseSettings, String deviceName, String pin)
+ throws IOException {
+ CastSettings peerSettings = cloneSettings(baseSettings);
+ peerSettings.setTransport(target.transport);
+ CastSession session = createSession(peerSettings);
+ connectWithRetry(session, target.host, target.port);
+ CastSession.HandshakeResult hs = session.clientHandshake(deviceName, pin, peerSettings);
+ peers.add(new ActivePeer(target, session, hs.settings, hs.negotiatedVideoMime));
+ if (keyframeRequester != null) {
+ keyframeRequester.requestKeyframe();
+ }
+ }
+
+ public void removePeer(String host, int port) {
+ String key = host + ":" + port;
+ for (ActivePeer p : peers) {
+ if (p.target.key().equals(key)) {
+ peers.remove(p);
+ p.session.close();
+ break;
+ }
+ }
+ }
+
+ public CastProtocol.Message pollReceive(int timeoutMs) throws IOException {
+ CastProtocol.Message any = null;
+ for (ActivePeer p : peers) {
+ CastProtocol.Message msg = p.session.receive(timeoutMs);
+ if (msg != null) {
+ any = msg;
+ }
+ }
+ return any;
+ }
+
+ public void closeAll() {
+ for (ActivePeer p : peers) {
+ try {
+ p.session.send(CastProtocol.MSG_GOODBYE, new byte[0]);
+ } catch (IOException ignored) {
+ }
+ p.session.close();
+ }
+ peers.clear();
+ }
+
+ private CastSession createSession(CastSettings settings) throws IOException {
+ CastSession session = new CastSession(CastTransportFactory.create(settings));
+ session.getTransport().setFramingContext(framingContext);
+ return session;
+ }
+
+ private void connectWithRetry(CastSession session, String host, int port) throws IOException {
+ IOException last = null;
+ for (int i = 0; i < CONNECT_RETRIES; i++) {
+ try {
+ session.connect(host, port);
+ return;
+ } catch (IOException e) {
+ last = e;
+ try {
+ Thread.sleep(1000);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ throw new IOException("Interrupted", ie);
+ }
+ }
+ }
+ throw last != null ? last : new IOException("Connect failed");
+ }
+
+ private static CastSettings cloneSettings(CastSettings src) {
+ CastSettings s = new CastSettings();
+ s.setTransport(src.getTransport());
+ s.setQuality(src.getQuality());
+ s.setResolution(src.getResolution());
+ s.setAudioEnabled(src.isAudioEnabled());
+ s.setVideoCodec(src.getVideoCodec());
+ s.setAdjustmentPreset(src.getAdjustmentPreset());
+ s.setBitrateMode(src.getBitrateMode());
+ s.setNetworkAdoption(src.getNetworkAdoption());
+ s.setCaptureMode(src.getCaptureMode());
+ return s;
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
index 52a3812..8275113 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
@@ -20,6 +20,7 @@ import android.util.Log;
import android.view.Surface;
import android.view.WindowManager;
+import com.foxx.androidcast.CastActiveState;
import com.foxx.androidcast.CastNotifications;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastResolution;
@@ -37,6 +38,8 @@ import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -54,6 +57,7 @@ public class ScreenCastService extends Service {
public static final String EXTRA_PORT = "port";
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 ACTION_STOP = "com.foxx.androidcast.STOP_SEND";
private final RemoteCallbackList callbacks = new RemoteCallbackList<>();
@@ -68,10 +72,15 @@ public class ScreenCastService extends Service {
private CastTuningEngine.EffectiveParams effectiveParams;
private long lastSignificantFrameMs;
private final SenderStreamMetrics senderMetrics = new SenderStreamMetrics();
+ private final SenderAudioMetrics senderAudioMetrics = new SenderAudioMetrics();
private AudioEncoder audioEncoder;
private AudioCapture audioCapture;
+ private long audioFramesSent;
+ private volatile String audioCaptureMode = "playback";
private CastSession session;
private CastSendPump sendPump;
+ private CastFanoutPump fanoutPump;
+ private MultiCastCoordinator multiCoordinator;
private NetworkFeedbackManager networkFeedback;
private PowerManager.WakeLock wakeLock;
private Thread ioThread;
@@ -131,7 +140,9 @@ public class ScreenCastService extends Service {
if (casting.get()) {
return START_STICKY;
}
- startSenderForeground(getString(R.string.connecting));
+ CastSettings startSettings = IntentExtras.getCastSettings(intent);
+ startSenderForeground(getString(R.string.connecting),
+ startSettings != null && startSettings.isAudioEnabled());
ioThread = new Thread(() -> runCast(intent), "ScreenCastIO");
ioThread.start();
return START_STICKY;
@@ -161,20 +172,43 @@ public class ScreenCastService extends Service {
acquireWakeLock();
try {
+ ArrayList targets = IntentExtras.getReceiverTargets(intent, EXTRA_TARGETS);
+ if (targets == null || targets.isEmpty()) {
+ targets = new ArrayList<>();
+ targets.add(new CastReceiverTarget(
+ deviceName != null ? deviceName : host, host, port, settings.getTransport(), 0, 0, 0));
+ }
+ multiCoordinator = new MultiCastCoordinator(this);
+ multiCoordinator.setKeyframeRequester(() -> {
+ if (videoEncoder != null) {
+ videoEncoder.requestKeyframe();
+ }
+ });
updateStatus(getString(R.string.connecting_to, host));
- session = new CastSession(CastTransportFactory.create(settings));
- updateStatus(getString(R.string.waiting_receiver, host, port));
- connectWithRetry(host, port, settings);
+ multiCoordinator.connectAndHandshake(targets, settings,
+ deviceName != null ? deviceName : "Sender", pin);
+ List peers = multiCoordinator.getPeers();
+ if (peers.isEmpty()) {
+ throw new IOException("No receivers connected");
+ }
+ MultiCastCoordinator.ActivePeer primary = peers.get(0);
+ session = primary.session;
+ settings = primary.settings;
+ settings.setNegotiatedVideoMime(primary.negotiatedMime);
updateStatus(getString(R.string.authenticating));
- CastSession.HandshakeResult handshake = session.clientHandshake(
- deviceName != null ? deviceName : "Sender", pin, settings);
- settings = handshake.settings;
networkFeedback = new NetworkFeedbackManager(session, true);
PassThroughNetworkControlPlane plane =
(PassThroughNetworkControlPlane) networkFeedback.getPlane();
- sendPump = new CastSendPump();
- sendPump.start(session, () -> stopping.set(true), plane);
+ plane.setStatsEnricher(this::enrichSenderStats);
+ if (peers.size() > 1) {
+ fanoutPump = new CastFanoutPump();
+ fanoutPump.start(multiCoordinator.getSessions(), () -> stopping.set(true), plane);
+ } else {
+ sendPump = new CastSendPump();
+ sendPump.start(session, () -> stopping.set(true), plane);
+ }
casting.set(true);
+ CastActiveState.setSenderCasting(true);
activeSettings = settings;
startCaptureOnMainThread(resultCode, new Intent(data), settings);
scheduleTuningTick();
@@ -182,7 +216,9 @@ public class ScreenCastService extends Service {
try {
if (networkFeedback != null) {
networkFeedback.tick();
- CastProtocol.Message msg = session.receive(100);
+ CastProtocol.Message msg = multiCoordinator != null
+ ? multiCoordinator.pollReceive(100)
+ : session.receive(100);
if (msg != null) {
if (networkFeedback.handleMessage(msg)) {
networkFeedback.applyAdvice(networkFeedback.getPlane().getOutboundAdvice());
@@ -352,7 +388,7 @@ public class ScreenCastService extends Service {
@Override
public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
- if (sendPump == null || frameData == null) {
+ if ((sendPump == null && fanoutPump == null) || frameData == null) {
return;
}
boolean tiny = effectiveParams != null && effectiveParams.skipTinyPFrames
@@ -363,7 +399,7 @@ public class ScreenCastService extends Service {
}
lastSignificantFrameMs = System.currentTimeMillis();
senderMetrics.onEncodedFrame(keyFrame);
- sendPump.enqueueVideo(ptsUs, keyFrame, frameData);
+ enqueueVideoFrame(ptsUs, keyFrame, frameData);
}
};
@@ -439,6 +475,40 @@ public class ScreenCastService extends Service {
return null;
}
+ private void enrichSenderStats(NetworkStatsSnapshot s, long nowMs) {
+ s.encodeFps = senderMetrics.getLastSecondFps();
+ if (fanoutPump != null) {
+ s.encodeQueueDepth = fanoutPump.getQueueDepth();
+ s.droppedPFrames = fanoutPump.getDroppedPFrames();
+ } else if (sendPump != null) {
+ s.encodeQueueDepth = sendPump.getQueueDepth();
+ s.droppedPFrames = sendPump.getDroppedPFrames();
+ }
+ s.audioActive = audioEncoder != null && audioFramesSent > 0;
+ s.audioFramesSent = audioFramesSent;
+ s.audioCaptureVuPercent = senderAudioMetrics.getVuPercent();
+ s.audioCaptureSilentBlocks = senderAudioMetrics.getSilentBlocks();
+ s.audioCaptureAudibleBlocks = senderAudioMetrics.getAudibleBlocks();
+ s.audioCapturePeakDb = senderAudioMetrics.getLastPeakDb();
+ s.audioCaptureRmsDb = senderAudioMetrics.getLastRmsDb();
+ if (effectiveParams != null) {
+ s.targetBitrateKbps = effectiveParams.videoBitrate / 1000;
+ }
+ if (activeSettings != null) {
+ CastTuningEngine.EffectiveParams eff = CastTuningEngine.resolve(activeSettings, getPeerStats());
+ s.suggestedBitrateKbps = eff.videoBitrate / 1000;
+ }
+ NetworkStatsSnapshot peer = getPeerStats();
+ if (peer != null && peer.recvBitrateKbps > 0 && s.sendBitrateKbps > peer.recvBitrateKbps * 1.2f) {
+ s.congestionLevel = Math.max(s.congestionLevel, 1);
+ }
+ int qDepth = fanoutPump != null ? fanoutPump.getQueueDepth()
+ : (sendPump != null ? sendPump.getQueueDepth() : 0);
+ if (qDepth > 20) {
+ s.congestionLevel = Math.max(s.congestionLevel, 2);
+ }
+ }
+
private void scheduleTuningTick() {
mainHandler.postDelayed(() -> {
if (!casting.get() || stopping.get() || activeSettings == null) {
@@ -465,7 +535,9 @@ public class ScreenCastService extends Service {
effectiveParams = next;
maybeAdaptCaptureSize(peer);
String mime = activeSettings != null ? activeSettings.getNegotiatedVideoMime() : null;
- updateStatus(senderMetrics.format(mime, captureWidth, captureHeight, sendPump));
+ CastSendPump pumpForStats = sendPump;
+ updateStatus(senderMetrics.format(mime, captureWidth, captureHeight, pumpForStats, senderAudioMetrics,
+ activeSettings != null && activeSettings.isAudioEnabled()));
scheduleTuningTick();
}, 2_000);
}
@@ -528,17 +600,32 @@ public class ScreenCastService extends Service {
if (frameData == null || frameData.length == 0) {
return;
}
+ audioFramesSent++;
sendSafe(CastProtocol.MSG_AUDIO_FRAME,
() -> CastProtocol.audioFramePayload(ptsUs, frameData));
}
});
- audioCapture = new AudioCapture();
- audioCapture.start(projection, (pcm, length, ptsUs) -> {
- if (audioEncoder != null) {
- audioEncoder.queuePcm(pcm, length, ptsUs);
+ audioFramesSent = 0;
+ senderAudioMetrics.reset();
+ audioCaptureMode = "playback";
+ audioCapture = new AudioCapture(this);
+ audioCapture.start(projection, new AudioCapture.Callback() {
+ @Override
+ public void onPcm(byte[] pcm, int length, long ptsUs,
+ com.foxx.androidcast.media.PcmLevelAnalyzer.Levels levels) {
+ senderAudioMetrics.onPcm(levels);
+ if (audioEncoder != null) {
+ audioEncoder.queuePcm(pcm, length, ptsUs);
+ }
+ }
+
+ @Override
+ public void onCaptureModeChanged(String mode) {
+ audioCaptureMode = mode;
+ Log.i(TAG, "Audio capture mode: " + mode);
}
});
- Log.i(TAG, "Audio pipeline started (playback capture)");
+ Log.i(TAG, "Audio pipeline started");
} catch (IOException e) {
Log.e(TAG, "Audio pipeline failed", e);
}
@@ -563,12 +650,25 @@ public class ScreenCastService extends Service {
}
}
+ private void enqueueVideoFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
+ if (fanoutPump != null) {
+ fanoutPump.enqueueVideo(ptsUs, keyFrame, frameData);
+ } else if (sendPump != null) {
+ sendPump.enqueueVideo(ptsUs, keyFrame, frameData);
+ }
+ }
+
private void sendSafe(byte type, PayloadBuilder builder) {
- if (stopping.get() || sendPump == null) {
+ if (stopping.get() || (sendPump == null && fanoutPump == null)) {
return;
}
try {
- sendPump.enqueue(type, builder.build());
+ byte[] payload = builder.build();
+ if (fanoutPump != null) {
+ fanoutPump.enqueue(type, payload);
+ } else {
+ sendPump.enqueue(type, payload);
+ }
} catch (IOException e) {
if (!stopping.get()) {
Log.w(TAG, "Payload build failed: " + e.getMessage());
@@ -597,6 +697,7 @@ public class ScreenCastService extends Service {
private void releaseAll() {
stopping.set(true);
casting.set(false);
+ CastActiveState.setSenderCasting(false);
unregisterDisplayListener();
networkFeedback = null;
if (virtualDisplay != null) {
@@ -623,11 +724,10 @@ public class ScreenCastService extends Service {
projection.stop();
projection = null;
}
- if (sendPump != null) {
- sendPump.stop();
- sendPump = null;
- }
- if (session != null) {
+ if (multiCoordinator != null) {
+ multiCoordinator.closeAll();
+ multiCoordinator = null;
+ } else if (session != null) {
try {
session.send(CastProtocol.MSG_GOODBYE, new byte[0]);
} catch (Exception ignored) {
@@ -635,6 +735,14 @@ public class ScreenCastService extends Service {
session.close();
session = null;
}
+ if (fanoutPump != null) {
+ fanoutPump.stop();
+ fanoutPump = null;
+ }
+ if (sendPump != null) {
+ sendPump.stop();
+ sendPump = null;
+ }
releaseWakeLock();
}
@@ -650,11 +758,14 @@ public class ScreenCastService extends Service {
stopSelf();
}
- private void startSenderForeground(String text) {
+ private void startSenderForeground(String text, boolean withAudio) {
android.app.Notification n = CastNotifications.sender(this, text, ScreenCastService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
- startForeground(CastNotifications.ID_SENDER, n,
- ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION);
+ int fgsType = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION;
+ if (withAudio) {
+ fgsType |= ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE;
+ }
+ startForeground(CastNotifications.ID_SENDER, n, fgsType);
} else {
startForeground(CastNotifications.ID_SENDER, n);
}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java
index dd30d60..2e9098e 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java
@@ -2,6 +2,9 @@ package com.foxx.androidcast.sender;
import android.app.Activity;
import android.content.Intent;
+import android.graphics.drawable.Animatable;
+import android.graphics.drawable.Drawable;
+import android.media.projection.MediaProjectionConfig;
import android.media.projection.MediaProjectionManager;
import android.os.Build;
import android.os.Bundle;
@@ -11,16 +14,18 @@ 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;
import android.widget.TextView;
import android.widget.Toast;
+import androidx.activity.OnBackPressedCallback;
import androidx.appcompat.app.AppCompatActivity;
+import com.foxx.androidcast.CastActiveState;
import com.foxx.androidcast.CastConfig;
+import com.foxx.androidcast.sender.CastReceiverTarget;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.PermissionHelper;
import com.foxx.androidcast.PlatformCastSupport;
@@ -31,6 +36,7 @@ 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;
private final CastSettings castSettings = new CastSettings();
@@ -49,6 +55,7 @@ public class SenderActivity extends AppCompatActivity {
private int titleTapCount;
private long lastTitleTapMs;
private boolean useManualConnect;
+ private long lastBackPressMs;
private final Runnable discoveryWatchdog = new Runnable() {
@Override
@@ -65,7 +72,7 @@ public class SenderActivity extends AppCompatActivity {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_sender);
PermissionHelper.request(this);
- SettingsUi.bind(this, castSettings);
+ SettingsUi.bindSender(this, castSettings);
titleText = findViewById(R.id.text_sender_title);
pinInput = findViewById(R.id.edit_pin);
@@ -95,30 +102,72 @@ public class SenderActivity extends AppCompatActivity {
});
ListView listView = findViewById(R.id.list_devices);
- listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
- deviceAdapter = new DeviceListAdapter(this);
+ listView.setChoiceMode(CastConfig.MULTI_RECEIVER_ENABLED
+ ? ListView.CHOICE_MODE_MULTIPLE : ListView.CHOICE_MODE_SINGLE);
+ deviceAdapter = new DeviceListAdapter(this, CastConfig.MULTI_RECEIVER_ENABLED);
listView.setAdapter(deviceAdapter);
listView.setOnItemClickListener((parent, view, position, id) -> {
deviceAdapter.setSelectedPosition(position);
useManualConnect = false;
- DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice();
- if (selected == null) {
+ java.util.List selectedList =
+ deviceAdapter.getSelectedDevices();
+ if (selectedList.isEmpty()) {
return;
}
- if (!castSettings.getTransport().equals(selected.transport)) {
- Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
+ if (!CastConfig.MULTI_RECEIVER_ENABLED) {
+ for (DiscoveryManager.DiscoveredDevice selected : selectedList) {
+ if (!castSettings.getTransport().equals(selected.transport)) {
+ Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
+ break;
+ }
+ }
+ }
+ if (selectedList.size() == 1) {
+ DiscoveryManager.DiscoveredDevice selected = selectedList.get(0);
+ statusText.setText(getString(R.string.device_selected, selected.name, selected.host,
+ selected.transport.toUpperCase()));
+ } else {
+ statusText.setText(getString(R.string.devices_selected, selectedList.size()));
}
- statusText.setText(getString(R.string.device_selected, selected.name, selected.host,
- selected.transport.toUpperCase()));
});
discovery = new DiscoveryManager(this);
- discovery.setListener(deviceList -> {
- discoveryProgress.setVisibility(View.GONE);
- deviceAdapter.setDevices(deviceList);
- statusText.setText(deviceList.isEmpty()
- ? getString(R.string.searching)
- : getString(R.string.found_receivers, deviceList.size()));
+ discovery.setListener(new DiscoveryManager.Listener() {
+ @Override
+ public void onDevicesUpdated(java.util.List deviceList) {
+ deviceAdapter.setDevices(deviceList);
+ statusText.setText(deviceList.isEmpty()
+ ? getString(R.string.searching)
+ : getString(R.string.found_receivers, deviceList.size()));
+ }
+
+ @Override
+ public void onScanStarted() {
+ showDiscoveryProgress();
+ }
+
+ @Override
+ public void onScanEnded() {
+ hideDiscoveryProgress();
+ }
+ });
+
+ getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
+ @Override
+ public void handleOnBackPressed() {
+ if (CastActiveState.isSenderCasting()) {
+ long now = System.currentTimeMillis();
+ if (now - lastBackPressMs < BACK_STOP_INTERVAL_MS) {
+ stopCastingAndExit();
+ } else {
+ lastBackPressMs = now;
+ Toast.makeText(SenderActivity.this,
+ R.string.press_back_again_to_stop_cast, Toast.LENGTH_SHORT).show();
+ }
+ } else {
+ finish();
+ }
+ }
});
findViewById(R.id.btn_refresh).setOnClickListener(v -> restartDiscovery());
@@ -130,6 +179,12 @@ public class SenderActivity extends AppCompatActivity {
});
}
+ @Override
+ protected void onResume() {
+ super.onResume();
+ PermissionHelper.remindIfMissing(this, castSettings.isAudioEnabled());
+ }
+
@Override
protected void onStart() {
super.onStart();
@@ -143,6 +198,7 @@ public class SenderActivity extends AppCompatActivity {
if (discovery != null) {
discovery.stop();
}
+ hideDiscoveryProgress();
super.onStop();
}
@@ -155,7 +211,6 @@ public class SenderActivity extends AppCompatActivity {
private void restartDiscoveryInternal(boolean userRefresh) {
deviceAdapter.clearDevices();
statusText.setText(userRefresh ? R.string.rescanning : R.string.searching);
- discoveryProgress.setVisibility(View.VISIBLE);
if (discovery != null) {
discovery.stop();
discovery.clearDevices();
@@ -163,7 +218,36 @@ public class SenderActivity extends AppCompatActivity {
discovery.startBrowsing();
}
+ private void showDiscoveryProgress() {
+ discoveryProgress.setVisibility(View.VISIBLE);
+ discoveryProgress.bringToFront();
+ Drawable indeterminate = discoveryProgress.getIndeterminateDrawable();
+ if (indeterminate instanceof Animatable) {
+ ((Animatable) indeterminate).start();
+ }
+ }
+
+ private void hideDiscoveryProgress() {
+ discoveryProgress.setVisibility(View.GONE);
+ Drawable indeterminate = discoveryProgress.getIndeterminateDrawable();
+ if (indeterminate instanceof Animatable) {
+ ((Animatable) indeterminate).stop();
+ }
+ }
+
+ private void stopCastingAndExit() {
+ Intent stop = new Intent(this, ScreenCastService.class);
+ stop.setAction(ScreenCastService.ACTION_STOP);
+ startService(stop);
+ CastActiveState.setSenderCasting(false);
+ finish();
+ }
+
private void startCastFlow() {
+ PermissionHelper.remindIfMissing(this, castSettings.isAudioEnabled());
+ if (!PermissionHelper.hasAll(this)) {
+ return;
+ }
if (castSettings.isPassthrough()) {
Toast.makeText(this, R.string.codec_passthrough_unavailable, Toast.LENGTH_LONG).show();
return;
@@ -190,29 +274,53 @@ public class SenderActivity extends AppCompatActivity {
}
pendingDeviceName = host;
} else {
- DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice();
- if (selected == null) {
+ java.util.List selectedList =
+ deviceAdapter.getSelectedDevices();
+ if (selectedList.isEmpty()) {
Toast.makeText(this, R.string.select_receiver, Toast.LENGTH_SHORT).show();
return;
}
- if (!castSettings.getTransport().equals(selected.transport)) {
- Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
+ if (CastConfig.MULTI_RECEIVER_ENABLED && selectedList.size() > CastConfig.MAX_RECEIVERS) {
+ Toast.makeText(this, getString(R.string.too_many_receivers, CastConfig.MAX_RECEIVERS),
+ Toast.LENGTH_LONG).show();
return;
}
- pendingHost = selected.host;
- pendingPort = selected.port;
- pendingDeviceName = selected.name;
+ pendingTargets = new java.util.ArrayList<>();
+ for (DiscoveryManager.DiscoveredDevice d : selectedList) {
+ if (!CastConfig.MULTI_RECEIVER_ENABLED
+ && !castSettings.getTransport().equals(d.transport)) {
+ Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
+ return;
+ }
+ pendingTargets.add(new CastReceiverTarget(d.name, d.host, d.port, d.transport,
+ d.videoCodecs, d.audioCodecs, d.placeholderId));
+ }
+ DiscoveryManager.DiscoveredDevice first = selectedList.get(0);
+ pendingHost = first.host;
+ pendingPort = first.port;
+ pendingDeviceName = selectedList.size() > 1
+ ? first.name + " +" + (selectedList.size() - 1) : first.name;
}
pendingPin = pin;
MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
- startActivityForResult(mgr.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION);
+ startActivityForResult(createScreenCaptureIntent(mgr), REQUEST_MEDIA_PROJECTION);
+ }
+
+ private android.content.Intent createScreenCaptureIntent(MediaProjectionManager mgr) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
+ && castSettings.getCaptureMode() == CastSettings.CaptureMode.USER_CHOICE) {
+ MediaProjectionConfig config = MediaProjectionConfig.createConfigForUserChoice();
+ return mgr.createScreenCaptureIntent(config);
+ }
+ return mgr.createScreenCaptureIntent();
}
private String pendingPin;
private String pendingHost;
private int pendingPort;
private String pendingDeviceName;
+ private java.util.ArrayList pendingTargets;
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
@@ -237,6 +345,9 @@ public class SenderActivity extends AppCompatActivity {
serviceIntent.putExtra(ScreenCastService.EXTRA_PIN, pendingPin);
serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, pendingDeviceName != null ? pendingDeviceName : deviceName);
serviceIntent.putExtra(CastSettings.EXTRA, castSettings);
+ if (pendingTargets != null && !pendingTargets.isEmpty()) {
+ serviceIntent.putExtra(ScreenCastService.EXTRA_TARGETS, pendingTargets);
+ }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent);
diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderAudioMetrics.java b/app/src/main/java/com/foxx/androidcast/sender/SenderAudioMetrics.java
new file mode 100644
index 0000000..16e1b43
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/sender/SenderAudioMetrics.java
@@ -0,0 +1,61 @@
+package com.foxx.androidcast.sender;
+
+import com.foxx.androidcast.media.PcmLevelAnalyzer;
+
+/** Pre-encode PCM level stats on the sender. */
+public final class SenderAudioMetrics {
+ private long silentBlocks;
+ private long audibleBlocks;
+ private float vuSmoothed;
+ private float lastPeakDb = -96f;
+ private float lastRmsDb = -96f;
+
+ public synchronized void onPcm(PcmLevelAnalyzer.Levels levels) {
+ if (levels == null) {
+ return;
+ }
+ if (levels.silent) {
+ silentBlocks++;
+ } else {
+ audibleBlocks++;
+ }
+ float instant = Math.max(levels.peak, levels.rms);
+ vuSmoothed = instant > vuSmoothed
+ ? instant * 0.35f + vuSmoothed * 0.65f
+ : instant * 0.12f + vuSmoothed * 0.88f;
+ if (levels.peak > 1e-9f) {
+ lastPeakDb = levels.peakDb();
+ }
+ if (levels.rms > 1e-9f) {
+ lastRmsDb = (float) (20.0 * Math.log10(levels.rms));
+ }
+ }
+
+ public synchronized void reset() {
+ silentBlocks = 0;
+ audibleBlocks = 0;
+ vuSmoothed = 0f;
+ lastPeakDb = -96f;
+ lastRmsDb = -96f;
+ }
+
+ public synchronized long getSilentBlocks() {
+ return silentBlocks;
+ }
+
+ public synchronized long getAudibleBlocks() {
+ return audibleBlocks;
+ }
+
+ public synchronized int getVuPercent() {
+ return PcmLevelAnalyzer.vuPercent(vuSmoothed);
+ }
+
+ public synchronized float getLastPeakDb() {
+ return lastPeakDb;
+ }
+
+ public synchronized float getLastRmsDb() {
+ return lastRmsDb;
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderStreamMetrics.java b/app/src/main/java/com/foxx/androidcast/sender/SenderStreamMetrics.java
index 9693656..aecd2d4 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/SenderStreamMetrics.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/SenderStreamMetrics.java
@@ -27,6 +27,10 @@ public final class SenderStreamMetrics {
windowFrames++;
}
+ public synchronized float getLastSecondFps() {
+ return lastSecondFps;
+ }
+
public synchronized void onNetworkSample(int sendKbps, int rttMs) {
if (sendKbps > 0) {
lastSendKbps = sendKbps;
@@ -36,7 +40,8 @@ public final class SenderStreamMetrics {
}
}
- public synchronized String format(String videoCodec, int captureW, int captureH, CastSendPump pump) {
+ public synchronized String format(String videoCodec, int captureW, int captureH, CastSendPump pump,
+ SenderAudioMetrics audio, boolean audioEnabled) {
float fps = lastSecondFps > 0 ? lastSecondFps : 0f;
StringBuilder sb = new StringBuilder();
if (videoCodec != null && !videoCodec.isEmpty()) {
@@ -60,6 +65,13 @@ public final class SenderStreamMetrics {
}
}
}
+ if (audioEnabled && audio != null) {
+ int vu = audio.getVuPercent();
+ sb.append(" · aud ").append(vu).append('%');
+ if (audio.getAudibleBlocks() == 0 && audio.getSilentBlocks() > 30) {
+ sb.append(" (silent cap)");
+ }
+ }
return sb.toString();
}
}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java b/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java
index 5fa6c34..7066c04 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java
@@ -32,6 +32,20 @@ public class VideoEncoder {
private Thread drainThread;
private String mime;
+ /** Request an IDR (keyframe) on the next opportunity. */
+ public void requestKeyframe() {
+ if (codec == null || !running) {
+ return;
+ }
+ try {
+ Bundle params = new Bundle();
+ params.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0);
+ codec.setParameters(params);
+ } catch (Exception e) {
+ Log.w(TAG, "Keyframe request failed: " + e.getMessage());
+ }
+ }
+
public Surface prepare(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
Callback callback) throws IOException {
this.width = width;
@@ -101,19 +115,6 @@ public class VideoEncoder {
}
}
- public void requestKeyframe() {
- if (codec == null) {
- return;
- }
- try {
- Bundle params = new Bundle();
- params.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0);
- codec.setParameters(params);
- } catch (Exception e) {
- Log.w(TAG, "Keyframe request failed", e);
- }
- }
-
public void stop() {
running = false;
if (drainThread != null) {
diff --git a/app/src/main/java/com/foxx/androidcast/ui/ZoomPanContainer.java b/app/src/main/java/com/foxx/androidcast/ui/ZoomPanContainer.java
new file mode 100644
index 0000000..6ed77c4
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/ui/ZoomPanContainer.java
@@ -0,0 +1,134 @@
+package com.foxx.androidcast.ui;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+import android.view.ScaleGestureDetector;
+import android.widget.FrameLayout;
+
+/**
+ * Pinch-zoom (5% steps) and two-finger pan over video content.
+ * Reports zoom deviation from 100% via {@link OnZoomChangedListener}.
+ */
+public class ZoomPanContainer extends FrameLayout {
+ public interface OnZoomChangedListener {
+ void onZoomPercentChanged(int zoomPercent);
+ }
+
+ private static final float STEP = 0.05f;
+ private static final float MIN_SCALE = 0.50f;
+ private static final float MAX_SCALE = 3.00f;
+
+ private float scale = 1f;
+ private float panX;
+ private float panY;
+ private OnZoomChangedListener zoomListener;
+
+ private final ScaleGestureDetector scaleDetector;
+ private final GestureDetector panDetector;
+ private float lastSpan = -1f;
+
+ public ZoomPanContainer(Context context) {
+ this(context, null);
+ }
+
+ public ZoomPanContainer(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ scaleDetector = new ScaleGestureDetector(context, new ScaleListener());
+ panDetector = new GestureDetector(context, new PanListener());
+ }
+
+ public void setOnZoomChangedListener(OnZoomChangedListener listener) {
+ this.zoomListener = listener;
+ }
+
+ public void resetTransform() {
+ scale = 1f;
+ panX = 0f;
+ panY = 0f;
+ applyTransform();
+ notifyZoom();
+ }
+
+ public int getZoomPercent() {
+ return Math.round((scale - 1f) * 100f);
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ boolean scaled = scaleDetector.onTouchEvent(event);
+ boolean panned = panDetector.onTouchEvent(event);
+ if (event.getActionMasked() == MotionEvent.ACTION_UP
+ || event.getActionMasked() == MotionEvent.ACTION_CANCEL) {
+ lastSpan = -1f;
+ }
+ return scaled || panned || super.onTouchEvent(event);
+ }
+
+ private final class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
+ @Override
+ public boolean onScale(ScaleGestureDetector detector) {
+ float span = detector.getCurrentSpan();
+ if (lastSpan < 0) {
+ lastSpan = span;
+ return true;
+ }
+ float ratio = span / lastSpan;
+ lastSpan = span;
+ if (ratio > 1.02f) {
+ applyStep(1f + STEP);
+ } else if (ratio < 0.98f) {
+ applyStep(1f - STEP);
+ }
+ return true;
+ }
+ }
+
+ private void applyStep(float factor) {
+ float target = scale * factor;
+ target = Math.max(MIN_SCALE, Math.min(MAX_SCALE, target));
+ int quantized = Math.round(target * 20f);
+ scale = quantized / 20f;
+ applyTransform();
+ notifyZoom();
+ }
+
+ private final class PanListener extends GestureDetector.SimpleOnGestureListener {
+ @Override
+ public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
+ if (e1 == null || e2 == null || e2.getPointerCount() < 2) {
+ return false;
+ }
+ panX -= distanceX;
+ panY -= distanceY;
+ applyTransform();
+ return true;
+ }
+ }
+
+ private void applyTransform() {
+ if (getChildCount() == 0) {
+ return;
+ }
+ android.view.View child = getChildAt(0);
+ child.setPivotX(child.getWidth() / 2f);
+ child.setPivotY(child.getHeight() / 2f);
+ child.setScaleX(scale);
+ child.setScaleY(scale);
+ child.setTranslationX(panX);
+ child.setTranslationY(panY);
+ }
+
+ private void notifyZoom() {
+ if (zoomListener != null) {
+ zoomListener.onZoomPercentChanged(getZoomPercent());
+ }
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ applyTransform();
+ }
+}
diff --git a/app/src/main/res/layout/activity_playback.xml b/app/src/main/res/layout/activity_playback.xml
index eb7beac..6186e97 100644
--- a/app/src/main/res/layout/activity_playback.xml
+++ b/app/src/main/res/layout/activity_playback.xml
@@ -4,19 +4,26 @@
android:layout_height="match_parent"
android:background="#000000">
-
-
-
+ android:layout_gravity="center">
+
+
+
+
diff --git a/app/src/main/res/layout/activity_settings_panel.xml b/app/src/main/res/layout/activity_settings_panel.xml
index b357305..5e5117b 100644
--- a/app/src/main/res/layout/activity_settings_panel.xml
+++ b/app/src/main/res/layout/activity_settings_panel.xml
@@ -88,6 +88,25 @@
android:layout_width="match_parent"
android:layout_height="wrap_content" />
+
+
+
+
+
+
+
Transport
Quality (bitrate / FPS)
Capture resolution
+ Screen capture (sender phone)
+ Screen capture (sender phone only)
+ Entire screen
+ Choose app or screen (Android 14+)
+ App picker needs Android 14 (this device: API %1$d)
Include audio (playback capture, Android 10+)
Audio unavailable (requires Android 10+)
Video only
@@ -52,6 +57,7 @@
Select a receiver first
Enter PIN
Screen capture permission denied
+ Allow microphone for audio cast fallback when nothing is playing on the sender.
Casting started
Found %1$d receiver(s)
Ready — PIN %1$s · %2$s
@@ -74,6 +80,11 @@
Listening for cast — awaiting sender…
Awaiting for stream to come…
Stream lost — awaiting for stream to come…
+ Cast ended — waiting for sender…
+ Press Back again to stop casting
+ Permissions needed
+ Grant
+ App settings
Starting receiver service…
Listening for cast
Connected — open video surface…
@@ -91,6 +102,8 @@
Fullscreen playback %1$dx%2$d
Video playing
Selected: %1$s (%2$s, %3$s)
+ Selected %1$d receivers
+ Select at most %1$d receivers
Play cast audio?
The sender is streaming audio. Play it on this device?
Play audio