1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 03:38:52 +03:00

proto changes, 1:m initial

This commit is contained in:
Anton Afanasyeu
2026-05-16 13:35:48 +02:00
parent 367a21e37a
commit a48fbe2ee7
52 changed files with 3411 additions and 352 deletions

View File

@@ -10,6 +10,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<application
android:allowBackup="true"
@@ -48,8 +50,8 @@
<service
android:name=".sender.ScreenCastService"
android:exported="false"
android:stopWithTask="true"
android:foregroundServiceType="mediaProjection" />
android:stopWithTask="false"
android:foregroundServiceType="mediaProjection|microphone" />
<service
android:name=".receiver.ReceiverCastService"

View File

@@ -0,0 +1,18 @@
package com.foxx.androidcast;
import java.util.concurrent.atomic.AtomicBoolean;
/** Process-wide cast session flags for UI back-key handling. */
public final class CastActiveState {
private static final AtomicBoolean senderCasting = new AtomicBoolean(false);
private CastActiveState() {}
public static void setSenderCasting(boolean active) {
senderCasting.set(active);
}
public static boolean isSenderCasting() {
return senderCasting.get();
}
}

View File

@@ -3,8 +3,15 @@ package com.foxx.androidcast;
/** Shared constants for the LAN cast POC. */
public final class CastConfig {
public static final int DISCOVERY_PORT = 41234;
/** One UDP browse pass duration (progress bar on sender). */
public static final long DISCOVERY_SCAN_PASS_MS = 4_000;
public static final int CAST_PORT = 41235;
public static final String DISCOVERY_MAGIC = "ACAST1";
/** When true, sender list allows selecting multiple receivers (1:N cast). */
public static final boolean MULTI_RECEIVER_ENABLED = true;
public static final int MAX_RECEIVERS = 5;
public static final String DEFAULT_PIN = "1234";
public static final String TRANSPORT_TCP = "tcp";

View File

@@ -5,7 +5,7 @@ import java.io.Serializable;
/** User-selected cast parameters (passed sender → service → receiver via protocol). */
public class CastSettings implements Serializable {
public static final String EXTRA = "cast_settings";
public static final int SETTINGS_VERSION = 2;
public static final int SETTINGS_VERSION = 3;
public enum Quality {
LOW(1_500_000, 24),
@@ -41,6 +41,14 @@ public class CastSettings implements Serializable {
* Capture size. {@link #HD_720P} caps height only (width follows aspect — e.g. 1532×720).
* {@link #FIXED_720P} fits inside 1280×720 box.
*/
/** How the system screen-capture consent dialog behaves (sender only). */
public enum CaptureMode {
/** Whole display (default). */
FULL_SCREEN,
/** Android 14+: system picker can target a single app (better app audio capture). */
USER_CHOICE
}
public enum Resolution {
FULL(1.0f, 0, 0),
THREE_QUARTERS(0.75f, 0, 0),
@@ -69,6 +77,7 @@ public class CastSettings implements Serializable {
private AdjustmentPreset adjustmentPreset = AdjustmentPreset.AUTO;
private BitrateMode bitrateMode = BitrateMode.AUTO;
private NetworkAdoption networkAdoption = NetworkAdoption.AUTO;
private CaptureMode captureMode = PlatformCastSupport.defaultCaptureMode();
/** Filled after codec handshake (e.g. video/avc). */
private transient String negotiatedVideoMime;
@@ -176,4 +185,12 @@ public class CastSettings implements Serializable {
public void setNegotiatedVideoMime(String negotiatedVideoMime) {
this.negotiatedVideoMime = negotiatedVideoMime;
}
public CaptureMode getCaptureMode() {
return captureMode;
}
public void setCaptureMode(CaptureMode captureMode) {
this.captureMode = captureMode != null ? captureMode : CaptureMode.FULL_SCREEN;
}
}

View File

@@ -0,0 +1,128 @@
package com.foxx.androidcast;
import android.content.Context;
import android.content.SharedPreferences;
import android.media.MediaDrm;
import android.os.Build;
import android.provider.Settings;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Locale;
import java.util.UUID;
/**
* Stable device identity for cast handshakes. Survives app reinstall when possible
* (Widevine ID + per-app ANDROID_ID hash). Cached in {@link SharedPreferences}.
*/
public final class DeviceInfo {
private static final String PREFS = "androidcast_device";
private static final String KEY_UUID = "device_uuid";
private static final UUID WIDEVINE_UUID =
UUID.fromString("edef8ba9-79d6-4ace-a3c8-27dcd51d21ed");
private static volatile String cachedUuid;
private DeviceInfo() {}
/** Returns a stable UUID string for this device (app-singleton). */
public static String getDeviceUuid(Context context) {
if (cachedUuid != null) {
return cachedUuid;
}
synchronized (DeviceInfo.class) {
if (cachedUuid != null) {
return cachedUuid;
}
Context app = context.getApplicationContext();
SharedPreferences prefs = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
String stored = prefs.getString(KEY_UUID, null);
if (stored != null && !stored.isEmpty()) {
cachedUuid = stored;
return cachedUuid;
}
String generated = generateStableUuid(app);
prefs.edit().putString(KEY_UUID, generated).apply();
cachedUuid = generated;
return cachedUuid;
}
}
/** Lower 32 bits of hashed UUID for {@code placeholder_id} in proto headers. */
public static int getPlaceholderId(Context context) {
return (int) (hashUuid(getDeviceUuid(context)) & 0xFFFFFFFFL);
}
private static String generateStableUuid(Context context) {
StringBuilder material = new StringBuilder();
material.append("androidcast:");
String androidId = Settings.Secure.getString(
context.getContentResolver(), Settings.Secure.ANDROID_ID);
if (androidId != null && !androidId.isEmpty()) {
material.append(androidId);
}
byte[] widevine = readWidevineId();
if (widevine != null && widevine.length > 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();
}
}
}

View File

@@ -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<CastReceiverTarget> 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<CastReceiverTarget>) intent.getSerializableExtra(key);
}
}

View File

@@ -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);

View File

@@ -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<String> 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<PermissionItem> 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<String> missing = missing(activity, includeAudio);
if (!missing.isEmpty()) {
ActivityCompat.requestPermissions(
activity, missing.toArray(new String[0]), REQUEST_CODE);
}
}
private static List<String> missing(Activity activity) {
List<String> needed = new ArrayList<>();
private static List<String> missing(Activity activity, boolean includeAudio) {
List<PermissionItem> items = describeMissing(activity, includeAudio);
List<String> perms = new ArrayList<>();
for (PermissionItem item : items) {
perms.add(item.permission);
}
return perms;
}
private static List<PermissionItem> describeMissing(Activity activity, boolean includeAudio) {
List<PermissionItem> 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<PermissionItem> 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);
}
}

View File

@@ -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());

View File

@@ -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 {

View File

@@ -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() {}
}

View File

@@ -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));
}
}

View File

@@ -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<String> 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("<font color='").append(COLOR_DIM).append("'>Transport: ")
.append(escape(transport != null ? transport.toUpperCase() : "?"))
.append("</font><br/>");
if (videoCodec != null && !videoCodec.isEmpty()) {
html.append("Codec: <font color='").append(COLOR_OK).append("'>")
.append(escape(CodecNegotiator.displayName(videoCodec))).append("</font><br/>");
}
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("<br/>");
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("<br/>");
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("<br/>");
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("<br/>");
html.append("Congestion: ").append(colored(String.valueOf(congestionLevel), congestionLevel > 0));
html.append("<br/>");
html.append("Ctrl: ").append(escape(ctrl));
html.append("<br/>");
html.append("Mode: ").append(escape(bitrateMode));
html.append("<br/>");
html.append("Net: ").append(escape(netAdopt));
html.append("<br/>");
if (peerSendKbps > 0 || peerRecvKbps > 0 || peerEncodeFps > 0) {
html.append("<font color='").append(COLOR_DIM).append("'>Sender:</font> ");
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("<br/>");
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("<br/>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(" <font color='").append(COLOR_WARN).append("'>")
.append("(sender capture silent)")
.append("</font>");
}
html.append("<br/>");
}
}
if (remoteSettings != null) {
html.append("<font color='").append(COLOR_DIM).append("'>Sender prefs:</font><br/>")
.append(escape(CastDiagnosticsLabels.adjustmentPreset(remoteSettings.getAdjustmentPreset())))
.append("<br/>")
.append(escape(CastDiagnosticsLabels.bitrateMode(remoteSettings.getBitrateMode())))
.append("<br/>")
.append(escape(CastDiagnosticsLabels.resolution(remoteSettings.getResolution())))
.append("<br/>")
.append(escape(CastDiagnosticsLabels.videoCodec(remoteSettings.getVideoCodec())))
.append("<br/>")
.append(escape(CastDiagnosticsLabels.captureMode(remoteSettings.getCaptureMode())));
if (audioRequested) {
html.append(" · audio");
if (peerAudioActive) {
html.append(" (").append(peerAudioFrames).append(" frm)");
}
}
html.append("<br/>");
}
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("<br/>");
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(" <font color='").append(COLOR_DIM).append("'>")
.append("(play media on sender)")
.append("</font>");
} else if (audioRequested && audioPackets > 10 && audioRendered == 0) {
html.append(" <font color='").append(COLOR_WARN).append("'>")
.append("(decode/render stalled)")
.append("</font>");
} else if (audioRequested && audioRendered > 15 && audioPcmAudible == 0
&& audioPcmSilent > 15) {
html.append(" <font color='").append(COLOR_WARN).append("'>")
.append("(decoded PCM is silence)")
.append("</font>");
if (peerCaptureSilent > 20 && peerCaptureAudible == 0) {
html.append(" <font color='").append(COLOR_WARN).append("'>")
.append("(fix on sender: Choose app, Android 14+)")
.append("</font>");
}
}
html.append("<br/>");
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("<br/>PCM blocks: silent ")
.append(colored(String.valueOf(audioPcmSilent),
audioPcmSilent > 20 && audioPcmAudible == 0))
.append(" audible ").append(String.valueOf(audioPcmAudible));
html.append("<br/>");
}
for (String hint : hints) {
html.append("<font color='").append(COLOR_WARN).append("'>")
.append(escape(hint)).append("</font><br/>");
}
html.append("Quality: <font color='").append(qualityColor).append("'>")
.append(quality).append("").append(CastDiagnosticsScorer.label(quality))
.append("</font><br/>");
html.append("<font color='").append(COLOR_DIM).append("'>State: ")
.append(idle ? "idle (awaiting)" : "streaming");
if (idleMs >= 0) {
html.append(" last frame: ").append(idleMs).append(" ms ago");
}
html.append("</font>");
return html.toString();
}
private static List<String> buildHints(float inFps, float renderFps, int bwInKbps,
int rttMs, long videoPackets) {
List<String> 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 — WiFi 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 "<font color='" + (bad ? COLOR_WARN : COLOR_OK) + "'>" + escape(text) + "</font>";
}
private static String escape(String s) {
if (s == null) {
return "";
}
return s.replace("&", "&amp;").replace("<", "&lt;");
}
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("<font color='#555'>[</font>");
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("<font color='").append(c).append("'>█</font>");
} else {
b.append("<font color='#333'>░</font>");
}
}
b.append("<font color='#555'>]</font>");
return b.toString();
}
}

View File

@@ -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";
}
}
}

View File

@@ -0,0 +1,62 @@
package com.foxx.androidcast.diagnostics;
/** Heuristic stream quality score (0100) 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";
}
}

View File

@@ -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<DiscoveredDevice> 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;

View File

@@ -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;
}
}
}

View File

@@ -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);
}
/** 0100 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);
}
}

View File

@@ -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<String> 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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<Integer, Reassembly> 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 {

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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<PendingFrame> 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();
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}

View File

@@ -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) {

View File

@@ -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) {
}

View File

@@ -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);

View File

@@ -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 — WiFi 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));
}
}

View File

@@ -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;
}
}
}

View File

@@ -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) {

View File

@@ -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<CastSendPump.Outgoing> queue = new LinkedBlockingQueue<>(MAX_QUEUE);
private final AtomicBoolean running = new AtomicBoolean(false);
private final List<CastSession> sessions = new ArrayList<>();
private Thread thread;
private Runnable onSendFailed;
private PassThroughNetworkControlPlane statsPlane;
private volatile int droppedPFrames;
public void setSessions(List<CastSession> list) {
sessions.clear();
if (list != null) {
sessions.addAll(list);
}
}
public int getDroppedPFrames() {
return droppedPFrames;
}
public int getQueueDepth() {
return queue.size();
}
public void start(List<CastSession> sessionList, Runnable onSendFailed,
PassThroughNetworkControlPlane statsPlane) {
setSessions(sessionList);
this.onSendFailed = onSendFailed;
this.statsPlane = statsPlane;
if (!running.compareAndSet(false, true)) {
return;
}
thread = new Thread(this::run, "CastFanoutPump");
thread.start();
}
public void stop() {
running.set(false);
if (thread != null) {
thread.interrupt();
try {
thread.join(1500);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
thread = null;
}
queue.clear();
sessions.clear();
}
public void enqueueVideo(long ptsUs, boolean keyFrame, byte[] annexBReadyPayload) {
if (!running.get()) {
return;
}
CastSendPump.Outgoing item = CastSendPump.Outgoing.video(ptsUs, keyFrame, annexBReadyPayload);
if (!queue.offer(item)) {
if (!keyFrame) {
CastSendPump.Outgoing dropped = queue.poll();
if (dropped != null && !dropped.keyFrame) {
droppedPFrames++;
}
}
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();
}
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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<DiscoveryManager.DiscoveredDevice> devices = new ArrayList<>();
private int selectedPosition = -1;
private final LayoutInflater inflater;
private final boolean multiSelect;
private final List<DiscoveryManager.DiscoveredDevice> devices = new ArrayList<>();
private int selectedPosition = -1;
private final Set<Integer> 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<DiscoveryManager.DiscoveredDevice> 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<DiscoveryManager.DiscoveredDevice> 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<DiscoveryManager.DiscoveredDevice> getSelectedDevices() {
List<DiscoveryManager.DiscoveredDevice> 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<DiscoveryManager.DiscoveredDevice> 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;
}
}

View File

@@ -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<ActivePeer> 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<ActivePeer> getPeers() {
return new ArrayList<>(peers);
}
public List<CastSession> getSessions() {
List<CastSession> out = new ArrayList<>();
for (ActivePeer p : peers) {
out.add(p.session);
}
return out;
}
public void connectAndHandshake(List<CastReceiverTarget> 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;
}
}

View File

@@ -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<ICastStatusCallback> 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<CastReceiverTarget> 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<MultiCastCoordinator.ActivePeer> 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);
}

View File

@@ -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<DiscoveryManager.DiscoveredDevice> 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<DiscoveryManager.DiscoveredDevice> 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<DiscoveryManager.DiscoveredDevice> 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<CastReceiverTarget> 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);

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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) {

View File

@@ -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();
}
}

View File

@@ -4,19 +4,26 @@
android:layout_height="match_parent"
android:background="#000000">
<com.foxx.androidcast.ui.AspectRatioFrameLayout
android:id="@+id/aspect_container"
<com.foxx.androidcast.ui.ZoomPanContainer
android:id="@+id/zoom_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center">
<SurfaceView
android:id="@+id/surface_playback"
<com.foxx.androidcast.ui.AspectRatioFrameLayout
android:id="@+id/aspect_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:keepScreenOn="true" />
</com.foxx.androidcast.ui.AspectRatioFrameLayout>
android:layout_gravity="center">
<SurfaceView
android:id="@+id/surface_playback"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:keepScreenOn="true" />
</com.foxx.androidcast.ui.AspectRatioFrameLayout>
</com.foxx.androidcast.ui.ZoomPanContainer>
<include
layout="@layout/overlay_stream_awaiting"

View File

@@ -106,8 +106,11 @@
android:id="@+id/progress_discovery"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="4dp"
android:layout_gravity="bottom"
android:layout_height="match_parent"
android:layout_gravity="center"
android:alpha="0.85"
android:clickable="false"
android:focusable="false"
android:indeterminate="true"
android:visibility="gone" />
</FrameLayout>

View File

@@ -88,6 +88,25 @@
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:id="@+id/layout_capture_mode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical">
<TextView
android:id="@+id/text_capture_mode_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/label_capture_mode" />
<Spinner
android:id="@+id/spinner_capture_mode"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
<CheckBox
android:id="@+id/checkbox_audio"
android:layout_width="match_parent"

View File

@@ -15,6 +15,11 @@
<string name="label_transport">Transport</string>
<string name="label_quality">Quality (bitrate / FPS)</string>
<string name="label_resolution">Capture resolution</string>
<string name="label_capture_mode">Screen capture (sender phone)</string>
<string name="label_capture_mode_sender_only">Screen capture (sender phone only)</string>
<string name="capture_full_screen">Entire screen</string>
<string name="capture_user_choice">Choose app or screen (Android 14+)</string>
<string name="capture_user_choice_unavailable">App picker needs Android 14 (this device: API %1$d)</string>
<string name="label_audio">Include audio (playback capture, Android 10+)</string>
<string name="label_audio_unavailable">Audio unavailable (requires Android 10+)</string>
<string name="audio_unavailable_title">Video only</string>
@@ -52,6 +57,7 @@
<string name="select_receiver">Select a receiver first</string>
<string name="enter_pin">Enter PIN</string>
<string name="capture_denied">Screen capture permission denied</string>
<string name="audio_mic_permission_hint">Allow microphone for audio cast fallback when nothing is playing on the sender.</string>
<string name="casting_started">Casting started</string>
<string name="found_receivers">Found %1$d receiver(s)</string>
<string name="receiver_ready">Ready — PIN %1$s · %2$s</string>
@@ -74,6 +80,11 @@
<string name="playback_listening">Listening for cast — awaiting sender…</string>
<string name="playback_awaiting_stream">Awaiting for stream to come…</string>
<string name="playback_stream_lost">Stream lost — awaiting for stream to come…</string>
<string name="playback_stream_ended">Cast ended — waiting for sender…</string>
<string name="press_back_again_to_stop_cast">Press Back again to stop casting</string>
<string name="permissions_missing_title">Permissions needed</string>
<string name="permissions_grant">Grant</string>
<string name="permissions_app_settings">App settings</string>
<string name="receiver_starting">Starting receiver service…</string>
<string name="receiver_listening">Listening for cast</string>
<string name="waiting_surface">Connected — open video surface…</string>
@@ -91,6 +102,8 @@
<string name="playback_opened">Fullscreen playback %1$dx%2$d</string>
<string name="playback_rendering">Video playing</string>
<string name="device_selected">Selected: %1$s (%2$s, %3$s)</string>
<string name="devices_selected">Selected %1$d receivers</string>
<string name="too_many_receivers">Select at most %1$d receivers</string>
<string name="audio_consent_title">Play cast audio?</string>
<string name="audio_consent_message">The sender is streaming audio. Play it on this device?</string>
<string name="audio_consent_yes">Play audio</string>