mirror of
git://f0xx.org/android_cast
synced 2026-07-29 06:39:09 +03:00
proto changes, 1:m initial
This commit is contained in:
@@ -10,6 +10,8 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<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
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -48,8 +50,8 @@
|
|||||||
<service
|
<service
|
||||||
android:name=".sender.ScreenCastService"
|
android:name=".sender.ScreenCastService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:stopWithTask="true"
|
android:stopWithTask="false"
|
||||||
android:foregroundServiceType="mediaProjection" />
|
android:foregroundServiceType="mediaProjection|microphone" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".receiver.ReceiverCastService"
|
android:name=".receiver.ReceiverCastService"
|
||||||
|
|||||||
18
app/src/main/java/com/foxx/androidcast/CastActiveState.java
Normal file
18
app/src/main/java/com/foxx/androidcast/CastActiveState.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,15 @@ package com.foxx.androidcast;
|
|||||||
/** Shared constants for the LAN cast POC. */
|
/** Shared constants for the LAN cast POC. */
|
||||||
public final class CastConfig {
|
public final class CastConfig {
|
||||||
public static final int DISCOVERY_PORT = 41234;
|
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 int CAST_PORT = 41235;
|
||||||
public static final String DISCOVERY_MAGIC = "ACAST1";
|
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 DEFAULT_PIN = "1234";
|
||||||
|
|
||||||
public static final String TRANSPORT_TCP = "tcp";
|
public static final String TRANSPORT_TCP = "tcp";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import java.io.Serializable;
|
|||||||
/** User-selected cast parameters (passed sender → service → receiver via protocol). */
|
/** User-selected cast parameters (passed sender → service → receiver via protocol). */
|
||||||
public class CastSettings implements Serializable {
|
public class CastSettings implements Serializable {
|
||||||
public static final String EXTRA = "cast_settings";
|
public static final String EXTRA = "cast_settings";
|
||||||
public static final int SETTINGS_VERSION = 2;
|
public static final int SETTINGS_VERSION = 3;
|
||||||
|
|
||||||
public enum Quality {
|
public enum Quality {
|
||||||
LOW(1_500_000, 24),
|
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).
|
* Capture size. {@link #HD_720P} caps height only (width follows aspect — e.g. 1532×720).
|
||||||
* {@link #FIXED_720P} fits inside 1280×720 box.
|
* {@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 {
|
public enum Resolution {
|
||||||
FULL(1.0f, 0, 0),
|
FULL(1.0f, 0, 0),
|
||||||
THREE_QUARTERS(0.75f, 0, 0),
|
THREE_QUARTERS(0.75f, 0, 0),
|
||||||
@@ -69,6 +77,7 @@ public class CastSettings implements Serializable {
|
|||||||
private AdjustmentPreset adjustmentPreset = AdjustmentPreset.AUTO;
|
private AdjustmentPreset adjustmentPreset = AdjustmentPreset.AUTO;
|
||||||
private BitrateMode bitrateMode = BitrateMode.AUTO;
|
private BitrateMode bitrateMode = BitrateMode.AUTO;
|
||||||
private NetworkAdoption networkAdoption = NetworkAdoption.AUTO;
|
private NetworkAdoption networkAdoption = NetworkAdoption.AUTO;
|
||||||
|
private CaptureMode captureMode = PlatformCastSupport.defaultCaptureMode();
|
||||||
/** Filled after codec handshake (e.g. video/avc). */
|
/** Filled after codec handshake (e.g. video/avc). */
|
||||||
private transient String negotiatedVideoMime;
|
private transient String negotiatedVideoMime;
|
||||||
|
|
||||||
@@ -176,4 +185,12 @@ public class CastSettings implements Serializable {
|
|||||||
public void setNegotiatedVideoMime(String negotiatedVideoMime) {
|
public void setNegotiatedVideoMime(String negotiatedVideoMime) {
|
||||||
this.negotiatedVideoMime = negotiatedVideoMime;
|
this.negotiatedVideoMime = negotiatedVideoMime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CaptureMode getCaptureMode() {
|
||||||
|
return captureMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCaptureMode(CaptureMode captureMode) {
|
||||||
|
this.captureMode = captureMode != null ? captureMode : CaptureMode.FULL_SCREEN;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
128
app/src/main/java/com/foxx/androidcast/DeviceInfo.java
Normal file
128
app/src/main/java/com/foxx/androidcast/DeviceInfo.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@ package com.foxx.androidcast;
|
|||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.sender.CastReceiverTarget;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
/** API 33+ safe extras helpers. */
|
/** API 33+ safe extras helpers. */
|
||||||
public final class IntentExtras {
|
public final class IntentExtras {
|
||||||
private IntentExtras() {}
|
private IntentExtras() {}
|
||||||
@@ -26,4 +30,15 @@ public final class IntentExtras {
|
|||||||
}
|
}
|
||||||
return (CastSettings) intent.getSerializableExtra(CastSettings.EXTRA);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
startActivity(new Intent(this, ReceiverActivity.class)));
|
startActivity(new Intent(this, ReceiverActivity.class)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
PermissionHelper.remindIfMissing(this, false);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
|
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
|
||||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||||
|
|||||||
@@ -2,41 +2,122 @@ package com.foxx.androidcast;
|
|||||||
|
|
||||||
import android.Manifest;
|
import android.Manifest;
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
import android.content.pm.PackageManager;
|
import android.content.pm.PackageManager;
|
||||||
|
import android.net.Uri;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
import android.provider.Settings;
|
||||||
|
|
||||||
|
import androidx.appcompat.app.AlertDialog;
|
||||||
import androidx.core.app.ActivityCompat;
|
import androidx.core.app.ActivityCompat;
|
||||||
import androidx.core.content.ContextCompat;
|
import androidx.core.content.ContextCompat;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
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 final class PermissionHelper {
|
||||||
public static final int REQUEST_CODE = 1001;
|
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() {}
|
private PermissionHelper() {}
|
||||||
|
|
||||||
public static boolean hasAll(Activity activity) {
|
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) {
|
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()) {
|
if (!missing.isEmpty()) {
|
||||||
ActivityCompat.requestPermissions(
|
ActivityCompat.requestPermissions(
|
||||||
activity, missing.toArray(new String[0]), REQUEST_CODE);
|
activity, missing.toArray(new String[0]), REQUEST_CODE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<String> missing(Activity activity) {
|
private static List<String> missing(Activity activity, boolean includeAudio) {
|
||||||
List<String> needed = new ArrayList<>();
|
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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS)
|
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
!= PackageManager.PERMISSION_GRANTED) {
|
!= 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;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ public final class PlatformCastSupport {
|
|||||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q;
|
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. */
|
/** Applies default audio flag: on for Android 10+, off otherwise. */
|
||||||
public static void applyDefaultAudioSetting(CastSettings settings) {
|
public static void applyDefaultAudioSetting(CastSettings settings) {
|
||||||
settings.setAudioEnabled(isAudioCaptureSupported());
|
settings.setAudioEnabled(isAudioCaptureSupported());
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.foxx.androidcast;
|
package com.foxx.androidcast;
|
||||||
|
|
||||||
|
import android.os.Build;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.widget.AdapterView;
|
import android.widget.AdapterView;
|
||||||
import android.widget.ArrayAdapter;
|
import android.widget.ArrayAdapter;
|
||||||
import android.widget.CheckBox;
|
import android.widget.CheckBox;
|
||||||
import android.widget.Spinner;
|
import android.widget.Spinner;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
|
||||||
@@ -12,12 +14,31 @@ import com.foxx.androidcast.sender.CodecCatalog;
|
|||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/** Binds cast option spinners/checkbox to {@link CastSettings}. */
|
/** Binds cast option spinners/checkbox to {@link CastSettings}. */
|
||||||
public final class SettingsUi {
|
public final class SettingsUi {
|
||||||
private 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) {
|
public static void bind(AppCompatActivity activity, CastSettings settings) {
|
||||||
|
bindSender(activity, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void bindCommon(AppCompatActivity activity, CastSettings settings) {
|
||||||
PlatformCastSupport.applyDefaultAudioSetting(settings);
|
PlatformCastSupport.applyDefaultAudioSetting(settings);
|
||||||
|
|
||||||
Spinner transport = activity.findViewById(R.id.spinner_transport);
|
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 quality = activity.findViewById(R.id.spinner_quality);
|
||||||
Spinner networkAdoption = activity.findViewById(R.id.spinner_network_adoption);
|
Spinner networkAdoption = activity.findViewById(R.id.spinner_network_adoption);
|
||||||
Spinner resolution = activity.findViewById(R.id.spinner_resolution);
|
Spinner resolution = activity.findViewById(R.id.spinner_resolution);
|
||||||
CheckBox audio = activity.findViewById(R.id.checkbox_audio);
|
|
||||||
|
|
||||||
bindSpinner(activity, transport, new String[]{
|
bindSpinner(activity, transport, new String[]{
|
||||||
activity.getString(R.string.transport_tcp),
|
activity.getString(R.string.transport_tcp),
|
||||||
@@ -78,8 +98,38 @@ public final class SettingsUi {
|
|||||||
bindSpinner(activity, resolution, resolutions,
|
bindSpinner(activity, resolution, resolutions,
|
||||||
position -> settings.setResolution(CastSettings.Resolution.values()[position]));
|
position -> settings.setResolution(CastSettings.Resolution.values()[position]));
|
||||||
resolution.setSelection(CastSettings.Resolution.HD_720P.ordinal());
|
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 {
|
private interface IntConsumer {
|
||||||
|
|||||||
18
app/src/main/java/com/foxx/androidcast/VersionInfo.java
Normal file
18
app/src/main/java/com/foxx/androidcast/VersionInfo.java
Normal 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() {}
|
||||||
|
}
|
||||||
64
app/src/main/java/com/foxx/androidcast/VersionUtils.java
Normal file
64
app/src/main/java/com/foxx/androidcast/VersionUtils.java
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 — Wi‑Fi congestion");
|
||||||
|
}
|
||||||
|
if (inFps > 12f && renderFps > 0 && renderFps < inFps * 0.7f) {
|
||||||
|
hints.add("Hint: decode slower than network (try H.264 or lower res)");
|
||||||
|
}
|
||||||
|
return hints;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String colored(String text, boolean bad) {
|
||||||
|
return "<font color='" + (bad ? COLOR_WARN : COLOR_OK) + "'>" + escape(text) + "</font>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String escape(String s) {
|
||||||
|
if (s == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return s.replace("&", "&").replace("<", "<");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String formatFloat(float v) {
|
||||||
|
if (v <= 0f) {
|
||||||
|
return "n/a";
|
||||||
|
}
|
||||||
|
return String.format(java.util.Locale.US, "%.1f", v);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String formatDb(float db) {
|
||||||
|
if (db <= -90f) {
|
||||||
|
return "-∞";
|
||||||
|
}
|
||||||
|
return String.format(java.util.Locale.US, "%.0f dB", db);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String vuBarHtml(int percent) {
|
||||||
|
int width = 16;
|
||||||
|
int filled = Math.min(width, (percent * width + 50) / 100);
|
||||||
|
StringBuilder b = new StringBuilder("<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.foxx.androidcast.diagnostics;
|
||||||
|
|
||||||
|
/** Heuristic stream quality score (0–100) from live metrics. */
|
||||||
|
public final class CastDiagnosticsScorer {
|
||||||
|
private CastDiagnosticsScorer() {}
|
||||||
|
|
||||||
|
public static int compute(float inFps, float renderFps, int bwInKbps, int suggestedBwKbps,
|
||||||
|
int rttMs, int congestionLevel, long audioPackets, boolean audioRequested) {
|
||||||
|
int score = 100;
|
||||||
|
if (inFps > 0 && inFps < 12f) {
|
||||||
|
score -= 25;
|
||||||
|
} else if (inFps > 0 && inFps < 20f) {
|
||||||
|
score -= 12;
|
||||||
|
}
|
||||||
|
if (inFps > 5f && renderFps > 0 && renderFps < inFps * 0.7f) {
|
||||||
|
score -= 22;
|
||||||
|
} else if (inFps > 5f && renderFps > 0 && renderFps < inFps * 0.85f) {
|
||||||
|
score -= 10;
|
||||||
|
}
|
||||||
|
if (suggestedBwKbps > 0 && bwInKbps > 0 && bwInKbps < suggestedBwKbps * 0.55f) {
|
||||||
|
score -= 20;
|
||||||
|
} else if (suggestedBwKbps > 0 && bwInKbps > 0 && bwInKbps < suggestedBwKbps * 0.75f) {
|
||||||
|
score -= 10;
|
||||||
|
}
|
||||||
|
if (rttMs > 600) {
|
||||||
|
score -= 18;
|
||||||
|
} else if (rttMs > 350) {
|
||||||
|
score -= 8;
|
||||||
|
}
|
||||||
|
if (congestionLevel >= 2) {
|
||||||
|
score -= 15;
|
||||||
|
} else if (congestionLevel >= 1) {
|
||||||
|
score -= 6;
|
||||||
|
}
|
||||||
|
if (audioRequested && audioPackets == 0) {
|
||||||
|
score -= 8;
|
||||||
|
}
|
||||||
|
if (score < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (score > 100) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String label(int score) {
|
||||||
|
if (score >= 85) {
|
||||||
|
return "Excellent";
|
||||||
|
}
|
||||||
|
if (score >= 70) {
|
||||||
|
return "Good";
|
||||||
|
}
|
||||||
|
if (score >= 50) {
|
||||||
|
return "Fair";
|
||||||
|
}
|
||||||
|
if (score >= 30) {
|
||||||
|
return "Poor";
|
||||||
|
}
|
||||||
|
return "Bad";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ import android.os.Looper;
|
|||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import com.foxx.androidcast.CastConfig;
|
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;
|
import org.json.JSONObject;
|
||||||
|
|
||||||
@@ -28,6 +33,14 @@ public class DiscoveryManager {
|
|||||||
|
|
||||||
public interface Listener {
|
public interface Listener {
|
||||||
void onDevicesUpdated(List<DiscoveredDevice> devices);
|
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 {
|
public static final class DiscoveredDevice {
|
||||||
@@ -36,13 +49,26 @@ public class DiscoveryManager {
|
|||||||
public final int port;
|
public final int port;
|
||||||
public final String transport;
|
public final String transport;
|
||||||
public final long lastSeenMs;
|
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) {
|
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.name = name;
|
||||||
this.host = host;
|
this.host = host;
|
||||||
this.port = port;
|
this.port = port;
|
||||||
this.transport = transport;
|
this.transport = transport;
|
||||||
this.lastSeenMs = lastSeenMs;
|
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)) {
|
if (!running.compareAndSet(false, true)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
notifyScanStarted();
|
||||||
executor.execute(this::browseLoop);
|
executor.execute(this::browseLoop);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,11 +126,16 @@ public class DiscoveryManager {
|
|||||||
startAnnouncing(deviceName, port, transport, true);
|
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)) {
|
if (!running.compareAndSet(false, true)) {
|
||||||
return;
|
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() {
|
private void browseLoop() {
|
||||||
@@ -113,11 +145,17 @@ public class DiscoveryManager {
|
|||||||
sock.setReuseAddress(true);
|
sock.setReuseAddress(true);
|
||||||
sock.bind(new InetSocketAddress(CastConfig.DISCOVERY_PORT));
|
sock.bind(new InetSocketAddress(CastConfig.DISCOVERY_PORT));
|
||||||
sock.setBroadcast(true);
|
sock.setBroadcast(true);
|
||||||
|
sock.setSoTimeout(300);
|
||||||
byte[] buf = new byte[1024];
|
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);
|
DatagramPacket packet = new DatagramPacket(buf, buf.length);
|
||||||
sock.receive(packet);
|
try {
|
||||||
handlePacket(packet);
|
sock.receive(packet);
|
||||||
|
handlePacket(packet);
|
||||||
|
} catch (java.net.SocketTimeoutException ignored) {
|
||||||
|
// keep listening until pass ends
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
if (running.get()) {
|
if (running.get()) {
|
||||||
@@ -127,11 +165,38 @@ public class DiscoveryManager {
|
|||||||
socket = null;
|
socket = null;
|
||||||
releaseMulticastLock();
|
releaseMulticastLock();
|
||||||
running.set(false);
|
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();
|
acquireMulticastLock();
|
||||||
|
int placeholderId = DeviceInfo.getPlaceholderId(context);
|
||||||
|
int videoMask = CastCodecFlags.localDecoderVideoMask();
|
||||||
|
int audioMask = CastCodecFlags.localAudioMask();
|
||||||
try (DatagramSocket sock = new DatagramSocket()) {
|
try (DatagramSocket sock = new DatagramSocket()) {
|
||||||
socket = sock;
|
socket = sock;
|
||||||
sock.setBroadcast(true);
|
sock.setBroadcast(true);
|
||||||
@@ -142,9 +207,17 @@ public class DiscoveryManager {
|
|||||||
json.put("port", port);
|
json.put("port", port);
|
||||||
json.put("transport", transport);
|
json.put("transport", transport);
|
||||||
json.put("listening", listening);
|
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");
|
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);
|
sock.send(packet);
|
||||||
Thread.sleep(2000);
|
Thread.sleep(2000);
|
||||||
}
|
}
|
||||||
@@ -161,8 +234,40 @@ public class DiscoveryManager {
|
|||||||
|
|
||||||
private void handlePacket(DatagramPacket packet) {
|
private void handlePacket(DatagramPacket packet) {
|
||||||
try {
|
try {
|
||||||
String text = new String(packet.getData(), packet.getOffset(), packet.getLength(),
|
byte[] raw = packet.getData();
|
||||||
StandardCharsets.UTF_8);
|
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);
|
JSONObject json = new JSONObject(text);
|
||||||
if (!CastConfig.DISCOVERY_MAGIC.equals(json.optString("magic"))) {
|
if (!CastConfig.DISCOVERY_MAGIC.equals(json.optString("magic"))) {
|
||||||
return;
|
return;
|
||||||
@@ -175,13 +280,24 @@ public class DiscoveryManager {
|
|||||||
}
|
}
|
||||||
String host = packet.getAddress().getHostAddress();
|
String host = packet.getAddress().getHostAddress();
|
||||||
String key = host + ":" + port;
|
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);
|
devices.put(key, device);
|
||||||
notifyListener();
|
notifyListener();
|
||||||
} catch (Exception ignored) {
|
} 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() {
|
private void notifyListener() {
|
||||||
if (listener == null) {
|
if (listener == null) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.foxx.androidcast.media;
|
||||||
|
|
||||||
|
/** Peak/RMS analysis for 16-bit little-endian PCM. */
|
||||||
|
public final class PcmLevelAnalyzer {
|
||||||
|
/** ~-46 dBFS; below this counts as silence. */
|
||||||
|
public static final float SILENT_PEAK_THRESHOLD = 0.005f;
|
||||||
|
|
||||||
|
public static final class Levels {
|
||||||
|
public final float peak;
|
||||||
|
public final float rms;
|
||||||
|
public final boolean silent;
|
||||||
|
|
||||||
|
Levels(float peak, float rms, boolean silent) {
|
||||||
|
this.peak = peak;
|
||||||
|
this.rms = rms;
|
||||||
|
this.silent = silent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public float peakDb() {
|
||||||
|
if (peak <= 1e-9f) {
|
||||||
|
return -96f;
|
||||||
|
}
|
||||||
|
return (float) (20.0 * Math.log10(peak));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private PcmLevelAnalyzer() {}
|
||||||
|
|
||||||
|
public static Levels analyze(byte[] pcm, int length) {
|
||||||
|
if (pcm == null || length < 2) {
|
||||||
|
return new Levels(0f, 0f, true);
|
||||||
|
}
|
||||||
|
int samples = length / 2;
|
||||||
|
long sumSq = 0;
|
||||||
|
int peakAbs = 0;
|
||||||
|
for (int i = 0; i + 1 < length; i += 2) {
|
||||||
|
short s = (short) ((pcm[i] & 0xff) | (pcm[i + 1] << 8));
|
||||||
|
int a = Math.abs(s);
|
||||||
|
if (a > peakAbs) {
|
||||||
|
peakAbs = a;
|
||||||
|
}
|
||||||
|
sumSq += (long) s * s;
|
||||||
|
}
|
||||||
|
float peak = peakAbs / 32768f;
|
||||||
|
float rms = samples > 0
|
||||||
|
? (float) Math.sqrt(sumSq / (double) samples) / 32768f
|
||||||
|
: 0f;
|
||||||
|
boolean silent = peak < SILENT_PEAK_THRESHOLD && rms < SILENT_PEAK_THRESHOLD * 0.7f;
|
||||||
|
return new Levels(peak, rms, silent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0–100 visual level from RMS (scaled for speech/music). */
|
||||||
|
public static int vuPercent(float rms) {
|
||||||
|
if (rms <= 0f) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
float db = (float) (20.0 * Math.log10(rms + 1e-9));
|
||||||
|
float norm = (db + 60f) / 60f;
|
||||||
|
if (norm < 0f) {
|
||||||
|
norm = 0f;
|
||||||
|
}
|
||||||
|
if (norm > 1f) {
|
||||||
|
norm = 1f;
|
||||||
|
}
|
||||||
|
return Math.round(norm * 100f);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ public final class CastProtocol {
|
|||||||
public static final byte MSG_AUDIO_CONSENT = 13;
|
public static final byte MSG_AUDIO_CONSENT = 13;
|
||||||
public static final byte MSG_CODEC_CAPS = 14;
|
public static final byte MSG_CODEC_CAPS = 14;
|
||||||
public static final byte MSG_CODEC_SELECTED = 15;
|
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() {}
|
private CastProtocol() {}
|
||||||
|
|
||||||
@@ -77,6 +79,7 @@ public final class CastProtocol {
|
|||||||
dos.writeInt(settings.getAdjustmentPreset().ordinal());
|
dos.writeInt(settings.getAdjustmentPreset().ordinal());
|
||||||
dos.writeInt(settings.getBitrateMode().ordinal());
|
dos.writeInt(settings.getBitrateMode().ordinal());
|
||||||
dos.writeInt(settings.getNetworkAdoption().ordinal());
|
dos.writeInt(settings.getNetworkAdoption().ordinal());
|
||||||
|
dos.writeInt(settings.getCaptureMode().ordinal());
|
||||||
dos.flush();
|
dos.flush();
|
||||||
return bos.toByteArray();
|
return bos.toByteArray();
|
||||||
}
|
}
|
||||||
@@ -108,6 +111,10 @@ public final class CastProtocol {
|
|||||||
settings.setNetworkAdoption(readEnum(CastSettings.NetworkAdoption.values(), dis.readInt(),
|
settings.setNetworkAdoption(readEnum(CastSettings.NetworkAdoption.values(), dis.readInt(),
|
||||||
CastSettings.NetworkAdoption.AUTO));
|
CastSettings.NetworkAdoption.AUTO));
|
||||||
}
|
}
|
||||||
|
if (dis.available() > 0) {
|
||||||
|
settings.setCaptureMode(readEnum(CastSettings.CaptureMode.values(), dis.readInt(),
|
||||||
|
CastSettings.CaptureMode.FULL_SCREEN));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
dis.reset();
|
dis.reset();
|
||||||
settings.setTransport(dis.readUTF());
|
settings.setTransport(dis.readUTF());
|
||||||
@@ -248,6 +255,8 @@ public final class CastProtocol {
|
|||||||
return local.getTransport().equals(remote.getTransport());
|
return local.getTransport().equals(remote.getTransport());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final byte NETWORK_STATS_VERSION = 3;
|
||||||
|
|
||||||
public static byte[] networkStatsPayload(NetworkStatsSnapshot s) throws IOException {
|
public static byte[] networkStatsPayload(NetworkStatsSnapshot s) throws IOException {
|
||||||
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||||
DataOutputStream dos = new DataOutputStream(bos);
|
DataOutputStream dos = new DataOutputStream(bos);
|
||||||
@@ -259,6 +268,21 @@ public final class CastProtocol {
|
|||||||
dos.writeInt(s.recvBitrateKbps);
|
dos.writeInt(s.recvBitrateKbps);
|
||||||
dos.writeInt(s.congestionLevel);
|
dos.writeInt(s.congestionLevel);
|
||||||
dos.writeInt(s.jitterMs);
|
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();
|
dos.flush();
|
||||||
return bos.toByteArray();
|
return bos.toByteArray();
|
||||||
}
|
}
|
||||||
@@ -274,6 +298,25 @@ public final class CastProtocol {
|
|||||||
s.recvBitrateKbps = dis.readInt();
|
s.recvBitrateKbps = dis.readInt();
|
||||||
s.congestionLevel = dis.readInt();
|
s.congestionLevel = dis.readInt();
|
||||||
s.jitterMs = 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;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import java.io.IOException;
|
|||||||
|
|
||||||
/** Pluggable stream transport (TCP or UDP). */
|
/** Pluggable stream transport (TCP or UDP). */
|
||||||
public interface CastTransport extends Closeable {
|
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. */
|
/** Server: bind and wait for first peer datagram / connection. */
|
||||||
void listen(int port) throws IOException;
|
void listen(int port) throws IOException;
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ import java.net.SocketTimeoutException;
|
|||||||
/** Reliable message transport over TCP (serialized send/receive). */
|
/** Reliable message transport over TCP (serialized send/receive). */
|
||||||
public class TcpCastTransport implements CastTransport {
|
public class TcpCastTransport implements CastTransport {
|
||||||
private static final int CONNECT_TIMEOUT_MS = 10_000;
|
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 final Object sendLock = new Object();
|
||||||
|
private CastFramingContext framingContext;
|
||||||
private final Object receiveLock = new Object();
|
private final Object receiveLock = new Object();
|
||||||
|
|
||||||
private ServerSocket serverSocket;
|
private ServerSocket serverSocket;
|
||||||
@@ -77,13 +78,25 @@ public class TcpCastTransport implements CastTransport {
|
|||||||
out = new DataOutputStream(new BufferedOutputStream(socket.getOutputStream()));
|
out = new DataOutputStream(new BufferedOutputStream(socket.getOutputStream()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setFramingContext(CastFramingContext context) {
|
||||||
|
this.framingContext = context;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void send(byte type, byte[] payload) throws IOException {
|
public void send(byte type, byte[] payload) throws IOException {
|
||||||
synchronized (sendLock) {
|
synchronized (sendLock) {
|
||||||
if (socket == null || socket.isClosed() || out == null) {
|
if (socket == null || socket.isClosed() || out == null) {
|
||||||
throw new IOException("Socket closed");
|
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 {
|
try {
|
||||||
socket.setSoTimeout(0);
|
socket.setSoTimeout(0);
|
||||||
|
if (framingContext != null) {
|
||||||
|
return readFramedMessage();
|
||||||
|
}
|
||||||
return CastProtocol.readMessage(in);
|
return CastProtocol.readMessage(in);
|
||||||
} catch (SocketTimeoutException e) {
|
} catch (SocketTimeoutException e) {
|
||||||
return null;
|
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 {
|
private boolean waitForHeader(int timeoutMs) throws IOException {
|
||||||
|
int need = framingContext != null ? CastProtoHeader.HEADER_SIZE_V1 : LEGACY_HEADER_BYTES;
|
||||||
long deadline = System.currentTimeMillis() + timeoutMs;
|
long deadline = System.currentTimeMillis() + timeoutMs;
|
||||||
while (System.currentTimeMillis() < deadline) {
|
while (System.currentTimeMillis() < deadline) {
|
||||||
if (bufferedIn.available() >= HEADER_BYTES) {
|
if (bufferedIn.available() >= need) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -129,7 +150,7 @@ public class TcpCastTransport implements CastTransport {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return bufferedIn.available() >= HEADER_BYTES;
|
return bufferedIn.available() >= need;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import java.net.DatagramPacket;
|
|||||||
import java.net.DatagramSocket;
|
import java.net.DatagramSocket;
|
||||||
import java.net.InetAddress;
|
import java.net.InetAddress;
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.SocketException;
|
||||||
import java.net.SocketTimeoutException;
|
import java.net.SocketTimeoutException;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
@@ -15,23 +16,31 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
/** Best-effort message transport over UDP with fragmentation. */
|
/** Best-effort message transport over UDP with fragmentation. */
|
||||||
public class UdpCastTransport implements CastTransport {
|
public class UdpCastTransport implements CastTransport {
|
||||||
private static final byte[] MAGIC = {'A', 'C', 'U', 'D'};
|
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 DatagramSocket socket;
|
||||||
private InetSocketAddress peer;
|
private InetSocketAddress peer;
|
||||||
private int nextMessageId = 1;
|
private int nextMessageId = 1;
|
||||||
|
private volatile boolean listening;
|
||||||
|
|
||||||
private final Map<Integer, Reassembly> pending = new ConcurrentHashMap<>();
|
private final Map<Integer, Reassembly> pending = new ConcurrentHashMap<>();
|
||||||
|
private CastFramingContext framingContext;
|
||||||
|
|
||||||
|
public boolean isListening() {
|
||||||
|
return listening && socket != null && !socket.isClosed();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void prepareListen(int port) throws IOException {
|
public void prepareListen(int port) throws IOException {
|
||||||
if (socket != null && !socket.isClosed()) {
|
if (isListening()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
socket = new DatagramSocket(null);
|
openSocket();
|
||||||
socket.setReuseAddress(true);
|
socket.bind(new InetSocketAddress(InetAddress.getByName("0.0.0.0"), port));
|
||||||
socket.bind(new InetSocketAddress(port));
|
|
||||||
socket.setSoTimeout(500);
|
socket.setSoTimeout(500);
|
||||||
|
listening = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -41,9 +50,25 @@ public class UdpCastTransport implements CastTransport {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void connect(String host, int port) throws IOException {
|
public void connect(String host, int port) throws IOException {
|
||||||
socket = new DatagramSocket();
|
openSocket();
|
||||||
peer = new InetSocketAddress(host, port);
|
peer = new InetSocketAddress(host, port);
|
||||||
socket.setSoTimeout(500);
|
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
|
@Override
|
||||||
@@ -51,7 +76,24 @@ public class UdpCastTransport implements CastTransport {
|
|||||||
if (peer == null) {
|
if (peer == null) {
|
||||||
throw new IOException("UDP peer not set");
|
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 messageId = nextMessageId++;
|
||||||
int chunkSize = CastConfig.UDP_CHUNK_SIZE - HEADER;
|
int chunkSize = CastConfig.UDP_CHUNK_SIZE - HEADER;
|
||||||
int fragments = Math.max(1, (body.length + chunkSize - 1) / chunkSize);
|
int fragments = Math.max(1, (body.length + chunkSize - 1) / chunkSize);
|
||||||
@@ -66,6 +108,9 @@ public class UdpCastTransport implements CastTransport {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CastProtocol.Message receive(int timeoutMs) throws IOException {
|
public CastProtocol.Message receive(int timeoutMs) throws IOException {
|
||||||
|
if (socket == null || socket.isClosed()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
long deadline = System.currentTimeMillis() + timeoutMs;
|
long deadline = System.currentTimeMillis() + timeoutMs;
|
||||||
while (System.currentTimeMillis() < deadline) {
|
while (System.currentTimeMillis() < deadline) {
|
||||||
int remaining = (int) Math.max(1, deadline - System.currentTimeMillis());
|
int remaining = (int) Math.max(1, deadline - System.currentTimeMillis());
|
||||||
@@ -105,13 +150,16 @@ public class UdpCastTransport implements CastTransport {
|
|||||||
byte[] chunk = new byte[payloadLen];
|
byte[] chunk = new byte[payloadLen];
|
||||||
System.arraycopy(data, HEADER, chunk, 0, payloadLen);
|
System.arraycopy(data, HEADER, chunk, 0, payloadLen);
|
||||||
|
|
||||||
|
CastProtocol.Message raw;
|
||||||
if (fragCount <= 1) {
|
if (fragCount <= 1) {
|
||||||
if (totalLen != payloadLen) {
|
if (totalLen != payloadLen && totalLen > 0 && totalLen <= payloadLen) {
|
||||||
byte[] trimmed = new byte[totalLen];
|
byte[] trimmed = new byte[totalLen];
|
||||||
System.arraycopy(chunk, 0, trimmed, 0, Math.min(totalLen, payloadLen));
|
System.arraycopy(chunk, 0, trimmed, 0, totalLen);
|
||||||
return new CastProtocol.Message(type, trimmed);
|
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);
|
Reassembly r = pending.get(messageId);
|
||||||
@@ -124,7 +172,14 @@ public class UdpCastTransport implements CastTransport {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
pending.remove(messageId);
|
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(
|
private static byte[] buildPacket(
|
||||||
@@ -170,15 +225,18 @@ public class UdpCastTransport implements CastTransport {
|
|||||||
@Override
|
@Override
|
||||||
public void releaseConnection() {
|
public void releaseConnection() {
|
||||||
peer = null;
|
peer = null;
|
||||||
|
pending.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
|
listening = false;
|
||||||
if (socket != null) {
|
if (socket != null) {
|
||||||
socket.close();
|
socket.close();
|
||||||
socket = null;
|
socket = null;
|
||||||
}
|
}
|
||||||
pending.clear();
|
pending.clear();
|
||||||
|
peer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class Reassembly {
|
private static final class Reassembly {
|
||||||
|
|||||||
@@ -11,6 +11,22 @@ public class NetworkStatsSnapshot {
|
|||||||
public int congestionLevel;
|
public int congestionLevel;
|
||||||
public int jitterMs;
|
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() {
|
public NetworkStatsSnapshot copy() {
|
||||||
NetworkStatsSnapshot c = new NetworkStatsSnapshot();
|
NetworkStatsSnapshot c = new NetworkStatsSnapshot();
|
||||||
c.sequence = sequence;
|
c.sequence = sequence;
|
||||||
@@ -21,6 +37,18 @@ public class NetworkStatsSnapshot {
|
|||||||
c.recvBitrateKbps = recvBitrateKbps;
|
c.recvBitrateKbps = recvBitrateKbps;
|
||||||
c.congestionLevel = congestionLevel;
|
c.congestionLevel = congestionLevel;
|
||||||
c.jitterMs = jitterMs;
|
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;
|
return c;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
|
|||||||
private long statsWindowStartMs;
|
private long statsWindowStartMs;
|
||||||
private long sendWindowBytes;
|
private long sendWindowBytes;
|
||||||
private long sendWindowStartMs;
|
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
|
@Override
|
||||||
public NetworkControlAdvice tick(long nowMs) {
|
public NetworkControlAdvice tick(long nowMs) {
|
||||||
@@ -75,6 +85,9 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
|
|||||||
s.recvBitrateKbps = estimateRecvKbps(nowMs);
|
s.recvBitrateKbps = estimateRecvKbps(nowMs);
|
||||||
s.congestionLevel = 0;
|
s.congestionLevel = 0;
|
||||||
s.jitterMs = 0;
|
s.jitterMs = 0;
|
||||||
|
if (statsEnricher != null) {
|
||||||
|
statsEnricher.enrich(s, nowMs);
|
||||||
|
}
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +1,310 @@
|
|||||||
package com.foxx.androidcast.receiver;
|
package com.foxx.androidcast.receiver;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
import android.media.AudioAttributes;
|
import android.media.AudioAttributes;
|
||||||
import android.media.AudioFormat;
|
import android.media.AudioFormat;
|
||||||
|
import android.media.AudioManager;
|
||||||
import android.media.AudioTrack;
|
import android.media.AudioTrack;
|
||||||
import android.media.MediaCodec;
|
import android.media.MediaCodec;
|
||||||
import android.media.MediaFormat;
|
import android.media.MediaFormat;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.HandlerThread;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.media.AacAdtsHelper;
|
||||||
|
import com.foxx.androidcast.media.PcmLevelAnalyzer;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
|
import java.util.ArrayList;
|
||||||
/** AAC decoder → AudioTrack playback. */
|
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 {
|
public class AudioDecoder {
|
||||||
private static final String TAG = "AudioDecoder";
|
private static final String TAG = "AudioDecoder";
|
||||||
private static final String MIME = MediaFormat.MIMETYPE_AUDIO_AAC;
|
private static final String MIME = MediaFormat.MIMETYPE_AUDIO_AAC;
|
||||||
|
|
||||||
|
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 MediaCodec codec;
|
||||||
private AudioTrack track;
|
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 {
|
public void configure(int sampleRate, int channels, byte[] csd0) throws IOException {
|
||||||
MediaFormat format = MediaFormat.createAudioFormat(MIME, sampleRate, channels);
|
final IOException[] error = new IOException[1];
|
||||||
if (csd0 != null && csd0.length > 0) {
|
final Object lock = new Object();
|
||||||
format.setByteBuffer("csd-0", ByteBuffer.wrap(csd0));
|
worker.post(() -> {
|
||||||
}
|
try {
|
||||||
codec = MediaCodec.createDecoderByType(MIME);
|
releaseOnWorker();
|
||||||
codec.configure(format, null, null, 0);
|
AudioDecoder.this.sampleRate = sampleRate;
|
||||||
codec.start();
|
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 minBuf = AudioTrack.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT);
|
||||||
|
int bufSize = Math.max(minBuf * 8, 65536);
|
||||||
track = new AudioTrack.Builder()
|
track = new AudioTrack.Builder()
|
||||||
.setAudioAttributes(new AudioAttributes.Builder()
|
.setAudioAttributes(new AudioAttributes.Builder()
|
||||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
|
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||||
.build())
|
.build())
|
||||||
.setAudioFormat(new AudioFormat.Builder()
|
.setAudioFormat(new AudioFormat.Builder()
|
||||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||||
.setSampleRate(sampleRate)
|
.setSampleRate(sampleRate)
|
||||||
.setChannelMask(channelMask)
|
.setChannelMask(channelMask)
|
||||||
.build())
|
.build())
|
||||||
.setBufferSizeInBytes(minBuf * 4)
|
.setBufferSizeInBytes(bufSize)
|
||||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||||
.build();
|
.build();
|
||||||
|
track.setVolume(1.0f);
|
||||||
|
requestAudioFocus();
|
||||||
track.play();
|
track.play();
|
||||||
configured = true;
|
Log.i(TAG, "AudioTrack play state=" + track.getPlayState());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void queueFrame(long ptsUs, byte[] data) {
|
public void queueFrame(long ptsUs, byte[] data) {
|
||||||
if (!configured || codec == null) {
|
if (data == null || data.length == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
stats.framesQueued++;
|
||||||
int inIndex = codec.dequeueInputBuffer(5_000);
|
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
|
||||||
if (inIndex < 0) {
|
inputQueue.poll();
|
||||||
return;
|
inputQueue.offer(new PendingFrame(ptsUs, data.clone()));
|
||||||
}
|
}
|
||||||
ByteBuffer buffer = codec.getInputBuffer(inIndex);
|
}
|
||||||
if (buffer == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
buffer.clear();
|
|
||||||
buffer.put(data);
|
|
||||||
codec.queueInputBuffer(inIndex, 0, data.length, ptsUs, 0);
|
|
||||||
|
|
||||||
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
private void drainLoop() {
|
||||||
int outIndex = codec.dequeueOutputBuffer(info, 5_000);
|
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
||||||
while (outIndex >= 0) {
|
while (draining) {
|
||||||
ByteBuffer outBuf = codec.getOutputBuffer(outIndex);
|
try {
|
||||||
if (outBuf != null && info.size > 0 && track != null) {
|
PendingFrame frame = inputQueue.poll(5, TimeUnit.MILLISECONDS);
|
||||||
byte[] pcm = new byte[info.size];
|
if (frame != null && codec != null) {
|
||||||
outBuf.position(info.offset);
|
feedFrameSync(frame, info);
|
||||||
outBuf.limit(info.offset + info.size);
|
}
|
||||||
outBuf.get(pcm);
|
drainOutputSync(info);
|
||||||
track.write(pcm, 0, pcm.length);
|
} 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() {
|
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;
|
configured = false;
|
||||||
|
inputQueue.clear();
|
||||||
|
if (drainThread != null) {
|
||||||
|
drainThread.interrupt();
|
||||||
|
try {
|
||||||
|
drainThread.join(1500);
|
||||||
|
} catch (InterruptedException ignored) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
drainThread = null;
|
||||||
|
}
|
||||||
if (codec != null) {
|
if (codec != null) {
|
||||||
try {
|
try {
|
||||||
codec.stop();
|
codec.stop();
|
||||||
@@ -102,4 +322,9 @@ public class AudioDecoder {
|
|||||||
track = null;
|
track = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void releaseAndQuit() {
|
||||||
|
release();
|
||||||
|
workerThread.quitSafely();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,7 +93,7 @@ public class ReceiverActivity extends AppCompatActivity {
|
|||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
setContentView(R.layout.activity_receiver);
|
setContentView(R.layout.activity_receiver);
|
||||||
PermissionHelper.request(this);
|
PermissionHelper.request(this);
|
||||||
SettingsUi.bind(this, castSettings);
|
SettingsUi.bindReceiver(this, castSettings);
|
||||||
|
|
||||||
statusText = findViewById(R.id.text_status);
|
statusText = findViewById(R.id.text_status);
|
||||||
pinInput = findViewById(R.id.edit_pin);
|
pinInput = findViewById(R.id.edit_pin);
|
||||||
@@ -129,6 +129,7 @@ public class ReceiverActivity extends AppCompatActivity {
|
|||||||
@Override
|
@Override
|
||||||
protected void onResume() {
|
protected void onResume() {
|
||||||
super.onResume();
|
super.onResume();
|
||||||
|
PermissionHelper.remindIfMissing(this, allowAudioCheck.isChecked());
|
||||||
updateKeepScreenOn();
|
updateKeepScreenOn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ import com.foxx.androidcast.ICastStatusCallback;
|
|||||||
import com.foxx.androidcast.IntentExtras;
|
import com.foxx.androidcast.IntentExtras;
|
||||||
import com.foxx.androidcast.R;
|
import com.foxx.androidcast.R;
|
||||||
import com.foxx.androidcast.CastConfig;
|
import com.foxx.androidcast.CastConfig;
|
||||||
|
import com.foxx.androidcast.diagnostics.CastDiagnosticsFormatter;
|
||||||
import com.foxx.androidcast.discovery.DiscoveryManager;
|
import com.foxx.androidcast.discovery.DiscoveryManager;
|
||||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||||
|
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
|
||||||
@@ -75,6 +77,8 @@ public class ReceiverCastService extends Service {
|
|||||||
private String activePin = com.foxx.androidcast.CastConfig.DEFAULT_PIN;
|
private String activePin = com.foxx.androidcast.CastConfig.DEFAULT_PIN;
|
||||||
private final StreamMetrics streamMetrics = new StreamMetrics();
|
private final StreamMetrics streamMetrics = new StreamMetrics();
|
||||||
private String negotiatedVideoMime;
|
private String negotiatedVideoMime;
|
||||||
|
private CastSettings remoteCastSettings;
|
||||||
|
private NetworkStatsSnapshot lastLocalStats;
|
||||||
private boolean streamIdle;
|
private boolean streamIdle;
|
||||||
private long lastVideoFrameMs;
|
private long lastVideoFrameMs;
|
||||||
|
|
||||||
@@ -161,7 +165,15 @@ public class ReceiverCastService extends Service {
|
|||||||
return START_STICKY;
|
return START_STICKY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final Object sessionLock = new Object();
|
||||||
|
|
||||||
private void restartListening(String pin) {
|
private void restartListening(String pin) {
|
||||||
|
synchronized (sessionLock) {
|
||||||
|
restartListeningLocked(pin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void restartListeningLocked(String pin) {
|
||||||
stopSessionOnly();
|
stopSessionOnly();
|
||||||
resetStreamState();
|
resetStreamState();
|
||||||
phase = Phase.LISTENING;
|
phase = Phase.LISTENING;
|
||||||
@@ -176,9 +188,11 @@ public class ReceiverCastService extends Service {
|
|||||||
audioDecoder.release();
|
audioDecoder.release();
|
||||||
}
|
}
|
||||||
videoDecoder = new VideoDecoder();
|
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
|
@Override
|
||||||
public void onListening() {
|
public void onListening() {
|
||||||
mainHandler.post(ReceiverCastService.this::startDiscoveryAnnouncing);
|
mainHandler.post(ReceiverCastService.this::startDiscoveryAnnouncing);
|
||||||
@@ -191,6 +205,11 @@ public class ReceiverCastService extends Service {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onAuthenticated(String senderName, CastSettings remoteSettings, String videoMime) {
|
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));
|
mainHandler.post(() -> onSenderConnected(senderName, remoteSettings, videoMime));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,16 +259,27 @@ public class ReceiverCastService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
final boolean accepted = accept;
|
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));
|
mainHandler.post(() -> onAudioConfigReceived(sampleRate, channels, csd0, accepted));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onAudioFrame(long ptsUs, byte[] data) {
|
public void onAudioFrame(long ptsUs, byte[] data) {
|
||||||
final int frameBytes = data != null ? data.length : 0;
|
final int frameBytes = data != null ? data.length : 0;
|
||||||
mainHandler.post(() -> streamMetrics.onAudioPacket(frameBytes));
|
final boolean accept;
|
||||||
if (Boolean.TRUE.equals(audioAccepted) && audioDecoder != null) {
|
synchronized (ReceiverCastService.this) {
|
||||||
|
accept = Boolean.TRUE.equals(audioAccepted);
|
||||||
|
}
|
||||||
|
if (accept && audioDecoder != null) {
|
||||||
audioDecoder.queueFrame(ptsUs, data);
|
audioDecoder.queueFrame(ptsUs, data);
|
||||||
}
|
}
|
||||||
|
mainHandler.post(() -> streamMetrics.onAudioPacket(frameBytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -265,7 +295,7 @@ public class ReceiverCastService extends Service {
|
|||||||
public void onPeerNetworkStats(NetworkStatsSnapshot stats) {
|
public void onPeerNetworkStats(NetworkStatsSnapshot stats) {
|
||||||
mainHandler.post(() -> streamMetrics.onPeerStats(stats));
|
mainHandler.post(() -> streamMetrics.onPeerStats(stats));
|
||||||
}
|
}
|
||||||
});
|
}, this::enrichReceiverStats);
|
||||||
session.start();
|
session.start();
|
||||||
updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase()));
|
updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase()));
|
||||||
openPlaybackAwaiting(R.string.playback_listening);
|
openPlaybackAwaiting(R.string.playback_listening);
|
||||||
@@ -275,7 +305,7 @@ public class ReceiverCastService extends Service {
|
|||||||
private void onSenderConnected(String senderName, CastSettings remoteSettings, String videoMime) {
|
private void onSenderConnected(String senderName, CastSettings remoteSettings, String videoMime) {
|
||||||
phase = Phase.CONNECTED;
|
phase = Phase.CONNECTED;
|
||||||
negotiatedVideoMime = videoMime;
|
negotiatedVideoMime = videoMime;
|
||||||
audioAccepted = null;
|
remoteCastSettings = remoteSettings;
|
||||||
streamIdle = false;
|
streamIdle = false;
|
||||||
streamMetrics.reset();
|
streamMetrics.reset();
|
||||||
lastVideoFrameMs = 0;
|
lastVideoFrameMs = 0;
|
||||||
@@ -305,13 +335,24 @@ public class ReceiverCastService extends Service {
|
|||||||
private void onSenderDisconnected() {
|
private void onSenderDisconnected() {
|
||||||
phase = Phase.LISTENING;
|
phase = Phase.LISTENING;
|
||||||
audioAccepted = null;
|
audioAccepted = null;
|
||||||
|
remoteCastSettings = null;
|
||||||
streamIdle = false;
|
streamIdle = false;
|
||||||
lastVideoFrameMs = 0;
|
lastVideoFrameMs = 0;
|
||||||
enterAwaitingMode(R.string.playback_stream_lost, true);
|
negotiatedVideoMime = null;
|
||||||
|
enterAwaitingMode(R.string.playback_stream_ended, true);
|
||||||
updateStatus(getString(R.string.waiting_sender));
|
updateStatus(getString(R.string.waiting_sender));
|
||||||
|
closePlaybackUi();
|
||||||
|
openReceiverSetupUi();
|
||||||
Log.i(TAG, "Sender disconnected");
|
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() {
|
private void checkStreamIdle() {
|
||||||
if (phase != Phase.STREAMING || streamIdle || lastVideoFrameMs <= 0) {
|
if (phase != Phase.STREAMING || streamIdle || lastVideoFrameMs <= 0) {
|
||||||
return;
|
return;
|
||||||
@@ -372,30 +413,19 @@ public class ReceiverCastService extends Service {
|
|||||||
pendingAudioChannels = channels;
|
pendingAudioChannels = channels;
|
||||||
pendingAudioCsd = csd0;
|
pendingAudioCsd = csd0;
|
||||||
if (accepted) {
|
if (accepted) {
|
||||||
configureAudioIfAccepted();
|
|
||||||
updateStatus(getString(R.string.audio_playing));
|
updateStatus(getString(R.string.audio_playing));
|
||||||
} else {
|
} else {
|
||||||
if (audioDecoder != null) {
|
if (audioDecoder != null) {
|
||||||
audioDecoder.release();
|
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));
|
updateStatus(getString(R.string.audio_skipped));
|
||||||
}
|
}
|
||||||
Log.i(TAG, "Audio consent: " + accepted);
|
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) {
|
private void onStreamConfigReceived(int width, int height, byte[] csd0, byte[] csd1) {
|
||||||
com.foxx.androidcast.media.StreamDimensionResolver.Size size =
|
com.foxx.androidcast.media.StreamDimensionResolver.Size size =
|
||||||
com.foxx.androidcast.media.StreamDimensionResolver.resolve(width, height, csd0);
|
com.foxx.androidcast.media.StreamDimensionResolver.resolve(width, height, csd0);
|
||||||
@@ -488,6 +518,11 @@ public class ReceiverCastService extends Service {
|
|||||||
session.stop();
|
session.stop();
|
||||||
session = null;
|
session = null;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
Thread.sleep(80);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void acquireWakeLock() {
|
private void acquireWakeLock() {
|
||||||
@@ -562,15 +597,41 @@ public class ReceiverCastService extends Service {
|
|||||||
mainHandler.post(() -> streamMetrics.onRenderedFrame());
|
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() {
|
private String buildDiagnosticsText() {
|
||||||
return streamMetrics.format(
|
NetworkStatsSnapshot peer = streamMetrics.getLastPeer();
|
||||||
|
return CastDiagnosticsFormatter.formatHtml(
|
||||||
|
streamMetrics,
|
||||||
settings.getTransport(),
|
settings.getTransport(),
|
||||||
negotiatedVideoMime,
|
negotiatedVideoMime,
|
||||||
pendingWidth,
|
pendingWidth,
|
||||||
pendingHeight,
|
pendingHeight,
|
||||||
decoderWidth,
|
decoderWidth,
|
||||||
decoderHeight,
|
decoderHeight,
|
||||||
streamIdle);
|
streamIdle,
|
||||||
|
settings,
|
||||||
|
remoteCastSettings,
|
||||||
|
peer,
|
||||||
|
lastLocalStats,
|
||||||
|
PlaybackViewState.getZoomPercent());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void attachSurface(Surface surface) {
|
private void attachSurface(Surface surface) {
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import com.foxx.androidcast.ICastReceiverService;
|
|||||||
import com.foxx.androidcast.ICastStatusCallback;
|
import com.foxx.androidcast.ICastStatusCallback;
|
||||||
import com.foxx.androidcast.R;
|
import com.foxx.androidcast.R;
|
||||||
import com.foxx.androidcast.ui.AspectRatioFrameLayout;
|
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;
|
* Fullscreen display: dancing-robot overlay while listening / waiting;
|
||||||
@@ -133,8 +136,10 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
|||||||
|
|
||||||
readSizeFromIntent(getIntent());
|
readSizeFromIntent(getIntent());
|
||||||
|
|
||||||
|
ZoomPanContainer zoomContainer = findViewById(R.id.zoom_container);
|
||||||
aspectContainer = findViewById(R.id.aspect_container);
|
aspectContainer = findViewById(R.id.aspect_container);
|
||||||
surfaceView = findViewById(R.id.surface_playback);
|
surfaceView = findViewById(R.id.surface_playback);
|
||||||
|
zoomContainer.setOnZoomChangedListener(PlaybackViewState::setZoomPercent);
|
||||||
awaitingOverlay = findViewById(R.id.overlay_awaiting);
|
awaitingOverlay = findViewById(R.id.overlay_awaiting);
|
||||||
diagnosticsText = findViewById(R.id.text_diagnostics);
|
diagnosticsText = findViewById(R.id.text_diagnostics);
|
||||||
robotView = findViewById(R.id.image_robot);
|
robotView = findViewById(R.id.image_robot);
|
||||||
@@ -228,6 +233,7 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
|||||||
|
|
||||||
private void showAwaitingOverlay(int messageResId) {
|
private void showAwaitingOverlay(int messageResId) {
|
||||||
streamRendering = false;
|
streamRendering = false;
|
||||||
|
PlaybackViewState.reset();
|
||||||
if (awaitingOverlay == null) {
|
if (awaitingOverlay == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -268,7 +274,7 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
|||||||
try {
|
try {
|
||||||
String text = receiverService.getDiagnosticsText();
|
String text = receiverService.getDiagnosticsText();
|
||||||
if (text != null) {
|
if (text != null) {
|
||||||
diagnosticsText.setText(text);
|
diagnosticsText.setText(HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY));
|
||||||
}
|
}
|
||||||
} catch (RemoteException ignored) {
|
} catch (RemoteException ignored) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ package com.foxx.androidcast.receiver;
|
|||||||
|
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
import com.foxx.androidcast.CastConfig;
|
import com.foxx.androidcast.CastConfig;
|
||||||
import com.foxx.androidcast.CastSettings;
|
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.CastProtocol;
|
||||||
import com.foxx.androidcast.network.CastSession;
|
import com.foxx.androidcast.network.CastSession;
|
||||||
import com.foxx.androidcast.network.CastTransport;
|
import com.foxx.androidcast.network.CastTransport;
|
||||||
@@ -44,6 +49,7 @@ public class ReceiverSession {
|
|||||||
void onPeerNetworkStats(NetworkStatsSnapshot stats);
|
void onPeerNetworkStats(NetworkStatsSnapshot stats);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final Context appContext;
|
||||||
private final String pin;
|
private final String pin;
|
||||||
private final CastSettings localSettings;
|
private final CastSettings localSettings;
|
||||||
private final Listener listener;
|
private final Listener listener;
|
||||||
@@ -53,14 +59,25 @@ public class ReceiverSession {
|
|||||||
private CastSession session;
|
private CastSession session;
|
||||||
private CastTransport listenTransport;
|
private CastTransport listenTransport;
|
||||||
private NetworkFeedbackManager networkFeedback;
|
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.pin = pin;
|
||||||
this.localSettings = localSettings;
|
this.localSettings = localSettings;
|
||||||
this.listener = listener;
|
this.listener = listener;
|
||||||
|
this.statsEnricher = statsEnricher;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void start() {
|
public void start() {
|
||||||
|
if (running.get()) {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
if (!running.compareAndSet(false, true)) {
|
if (!running.compareAndSet(false, true)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -95,6 +112,11 @@ public class ReceiverSession {
|
|||||||
try {
|
try {
|
||||||
if (listenTransport == null) {
|
if (listenTransport == null) {
|
||||||
listenTransport = CastTransportFactory.create(localSettings);
|
listenTransport = CastTransportFactory.create(localSettings);
|
||||||
|
listenTransport.setFramingContext(new CastFramingContext(
|
||||||
|
0,
|
||||||
|
DeviceInfo.getPlaceholderId(appContext),
|
||||||
|
CastCodecFlags.localDecoderVideoMask(),
|
||||||
|
CastCodecFlags.localAudioMask()));
|
||||||
session = new CastSession(listenTransport);
|
session = new CastSession(listenTransport);
|
||||||
session.prepareListen(CastConfig.CAST_PORT);
|
session.prepareListen(CastConfig.CAST_PORT);
|
||||||
listener.onListening();
|
listener.onListening();
|
||||||
@@ -104,6 +126,10 @@ public class ReceiverSession {
|
|||||||
session = new CastSession(listenTransport);
|
session = new CastSession(listenTransport);
|
||||||
}
|
}
|
||||||
networkFeedback = new NetworkFeedbackManager(session, false);
|
networkFeedback = new NetworkFeedbackManager(session, false);
|
||||||
|
if (networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) {
|
||||||
|
((PassThroughNetworkControlPlane) networkFeedback.getPlane())
|
||||||
|
.setStatsEnricher(statsEnricher);
|
||||||
|
}
|
||||||
session.acceptClient();
|
session.acceptClient();
|
||||||
CastSession.HandshakeResult hs = session.serverHandshake(pin, localSettings);
|
CastSession.HandshakeResult hs = session.serverHandshake(pin, localSettings);
|
||||||
listener.onAuthenticated(hs.senderName, hs.settings, hs.negotiatedVideoMime);
|
listener.onAuthenticated(hs.senderName, hs.settings, hs.negotiatedVideoMime);
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
|||||||
public final class StreamMetrics {
|
public final class StreamMetrics {
|
||||||
private long videoPackets;
|
private long videoPackets;
|
||||||
private long audioPackets;
|
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 keyFrames;
|
||||||
private long deltaFrames;
|
private long deltaFrames;
|
||||||
private long videoBytes;
|
private long videoBytes;
|
||||||
@@ -21,10 +27,17 @@ public final class StreamMetrics {
|
|||||||
private int peerRttMs;
|
private int peerRttMs;
|
||||||
private int peerRecvKbps;
|
private int peerRecvKbps;
|
||||||
private int peerSendKbps;
|
private int peerSendKbps;
|
||||||
|
private NetworkStatsSnapshot lastPeer;
|
||||||
|
|
||||||
public synchronized void reset() {
|
public synchronized void reset() {
|
||||||
videoPackets = 0;
|
videoPackets = 0;
|
||||||
audioPackets = 0;
|
audioPackets = 0;
|
||||||
|
audioRenderedWrites = 0;
|
||||||
|
audioPcmSilentBlocks = 0;
|
||||||
|
audioPcmAudibleBlocks = 0;
|
||||||
|
audioVuSmoothed = 0f;
|
||||||
|
audioLastPeakDb = -96f;
|
||||||
|
audioLastRmsDb = -96f;
|
||||||
keyFrames = 0;
|
keyFrames = 0;
|
||||||
deltaFrames = 0;
|
deltaFrames = 0;
|
||||||
videoBytes = 0;
|
videoBytes = 0;
|
||||||
@@ -40,6 +53,7 @@ public final class StreamMetrics {
|
|||||||
peerRttMs = 0;
|
peerRttMs = 0;
|
||||||
peerRecvKbps = 0;
|
peerRecvKbps = 0;
|
||||||
peerSendKbps = 0;
|
peerSendKbps = 0;
|
||||||
|
lastPeer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized void onVideoPacket(boolean keyFrame, int bytes) {
|
public synchronized void onVideoPacket(boolean keyFrame, int bytes) {
|
||||||
@@ -74,6 +88,51 @@ public final class StreamMetrics {
|
|||||||
audioBytes += bytes;
|
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() {
|
public synchronized void onRenderedFrame() {
|
||||||
renderedFrames++;
|
renderedFrames++;
|
||||||
lastRenderedFrameMs = System.currentTimeMillis();
|
lastRenderedFrameMs = System.currentTimeMillis();
|
||||||
@@ -83,72 +142,89 @@ public final class StreamMetrics {
|
|||||||
if (peer == null) {
|
if (peer == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
lastPeer = peer;
|
||||||
peerRttMs = peer.rttMs;
|
peerRttMs = peer.rttMs;
|
||||||
peerRecvKbps = peer.recvBitrateKbps;
|
peerRecvKbps = peer.recvBitrateKbps;
|
||||||
peerSendKbps = peer.sendBitrateKbps;
|
peerSendKbps = peer.sendBitrateKbps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public synchronized NetworkStatsSnapshot getLastPeer() {
|
||||||
|
return lastPeer;
|
||||||
|
}
|
||||||
|
|
||||||
public synchronized long getLastVideoPacketMs() {
|
public synchronized long getLastVideoPacketMs() {
|
||||||
return lastVideoPacketMs;
|
return lastVideoPacketMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized String format(String transport, String videoCodec, int streamW, int streamH, int renderW,
|
public synchronized float getInFps() {
|
||||||
int renderH, boolean idle) {
|
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
float inFps = lastSecondInFps > 0 ? lastSecondInFps : computeFps(videoPackets, firstVideoPacketMs, now);
|
float inFps = lastSecondInFps > 0 ? lastSecondInFps : computeFps(videoPackets, firstVideoPacketMs, now);
|
||||||
float renderFps = computeFps(renderedFrames, firstVideoPacketMs, lastRenderedFrameMs);
|
return inFps;
|
||||||
int localKbps = computeLocalKbps(now);
|
}
|
||||||
long idleMs = lastVideoPacketMs > 0 ? now - lastVideoPacketMs : -1;
|
|
||||||
|
|
||||||
|
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();
|
StringBuilder sb = new StringBuilder();
|
||||||
sb.append("Transport: ").append(transport != null ? transport.toUpperCase() : "?").append('\n');
|
sb.append("In FPS: ").append(String.format("%.1f", getInFps()));
|
||||||
if (videoCodec != null && !videoCodec.isEmpty()) {
|
sb.append(" Render: ").append(String.format("%.1f", getRenderFps()));
|
||||||
sb.append("Codec: ").append(
|
sb.append(" BW: ").append(getBwInKbps()).append(" kbps");
|
||||||
com.foxx.androidcast.media.CodecNegotiator.displayName(videoCodec)).append('\n');
|
sb.append(idle ? " idle" : " live");
|
||||||
}
|
|
||||||
sb.append("Stream: ").append(streamW).append('x').append(streamH);
|
|
||||||
if (renderW > 0 && renderH > 0 && (renderW != streamW || renderH != streamH)) {
|
|
||||||
sb.append(" render: ").append(renderW).append('x').append(renderH);
|
|
||||||
}
|
|
||||||
sb.append('\n');
|
|
||||||
sb.append("In FPS: ").append(formatFloat(inFps));
|
|
||||||
sb.append(" Render FPS: ").append(formatFloat(renderFps)).append('\n');
|
|
||||||
sb.append("BW in (est): ").append(localKbps > 0 ? localKbps + " kbps" : "n/a");
|
|
||||||
sb.append(" RTT: ").append(peerRttMs > 0 ? peerRttMs + " ms" : "n/a").append('\n');
|
|
||||||
if (peerRecvKbps > 0 || peerSendKbps > 0) {
|
|
||||||
sb.append("Sender (peer): tx ").append(peerSendKbps).append(" / rx ")
|
|
||||||
.append(peerRecvKbps).append(" kbps\n");
|
|
||||||
}
|
|
||||||
sb.append("Video pkts: ").append(videoPackets);
|
|
||||||
sb.append(" (IDR ").append(keyFrames).append(" / P ").append(deltaFrames).append(')');
|
|
||||||
if (videoPackets > 0) {
|
|
||||||
int avgKb = (int) (videoBytes / videoPackets / 1024);
|
|
||||||
sb.append(" ~").append(Math.max(1, avgKb)).append(" KB/frame");
|
|
||||||
}
|
|
||||||
sb.append('\n');
|
|
||||||
if (inFps > 0 && inFps < 8f) {
|
|
||||||
sb.append("Hint: low In FPS → sender encode stall / tiny frames / TCP backlog\n");
|
|
||||||
}
|
|
||||||
if (localKbps > 0 && localKbps < 350 && inFps < 8f && videoPackets > 30) {
|
|
||||||
sb.append("Hint: very low BW — static screen or throttled encoder\n");
|
|
||||||
}
|
|
||||||
if (peerRttMs > 450 && inFps < 15f) {
|
|
||||||
sb.append("Hint: high RTT ").append(peerRttMs).append(" ms — Wi‑Fi congestion\n");
|
|
||||||
}
|
|
||||||
if (renderFps > 0 && inFps > renderFps + 3f) {
|
|
||||||
sb.append("Hint: decode slower than network\n");
|
|
||||||
}
|
|
||||||
sb.append("Audio pkts: ").append(audioPackets);
|
|
||||||
if (audioPackets == 0 && videoPackets > 20) {
|
|
||||||
sb.append(" (play media on sender for capture audio)");
|
|
||||||
}
|
|
||||||
sb.append('\n');
|
|
||||||
sb.append("Video bytes: ").append(formatBytes(videoBytes));
|
|
||||||
sb.append(" Audio bytes: ").append(formatBytes(audioBytes)).append('\n');
|
|
||||||
sb.append("State: ").append(idle ? "idle (awaiting)" : "streaming");
|
|
||||||
if (idleMs >= 0) {
|
|
||||||
sb.append(" last frame: ").append(idleMs).append(" ms ago");
|
|
||||||
}
|
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,21 +255,4 @@ public final class StreamMetrics {
|
|||||||
}
|
}
|
||||||
return count / sec;
|
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,94 @@
|
|||||||
package com.foxx.androidcast.sender;
|
package com.foxx.androidcast.sender;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
import android.media.AudioAttributes;
|
import android.media.AudioAttributes;
|
||||||
import android.media.AudioFormat;
|
import android.media.AudioFormat;
|
||||||
import android.media.AudioPlaybackCaptureConfiguration;
|
import android.media.AudioPlaybackCaptureConfiguration;
|
||||||
import android.media.AudioRecord;
|
import android.media.AudioRecord;
|
||||||
|
import android.media.MediaRecorder;
|
||||||
import android.media.projection.MediaProjection;
|
import android.media.projection.MediaProjection;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.util.Log;
|
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 {
|
public class AudioCapture {
|
||||||
private static final String TAG = "AudioCapture";
|
private static final String TAG = "AudioCapture";
|
||||||
|
|
||||||
public interface Callback {
|
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 Thread thread;
|
||||||
private volatile boolean running;
|
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) {
|
public void start(MediaProjection projection, Callback callback) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||||
Log.w(TAG, "Audio capture requires API 29+");
|
Log.w(TAG, "Audio capture requires API 29+");
|
||||||
return;
|
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 channelMask = AudioFormat.CHANNEL_IN_STEREO;
|
||||||
int encoding = AudioFormat.ENCODING_PCM_16BIT;
|
int encoding = AudioFormat.ENCODING_PCM_16BIT;
|
||||||
int minBuf = AudioRecord.getMinBufferSize(CastConfig.AUDIO_SAMPLE_RATE, channelMask, encoding);
|
int minBuf = AudioRecord.getMinBufferSize(CastConfig.AUDIO_SAMPLE_RATE, channelMask, encoding);
|
||||||
@@ -38,46 +100,98 @@ public class AudioCapture {
|
|||||||
.setChannelMask(channelMask)
|
.setChannelMask(channelMask)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
AudioPlaybackCaptureConfiguration capConfig = new AudioPlaybackCaptureConfiguration.Builder(projection)
|
AudioPlaybackCaptureConfiguration.Builder capBuilder =
|
||||||
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
|
new AudioPlaybackCaptureConfiguration.Builder(projection);
|
||||||
.addMatchingUsage(AudioAttributes.USAGE_GAME)
|
capBuilder.addMatchingUsage(AudioAttributes.USAGE_MEDIA);
|
||||||
.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
|
capBuilder.addMatchingUsage(AudioAttributes.USAGE_GAME);
|
||||||
.addMatchingUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
|
capBuilder.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN);
|
||||||
.addMatchingUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY)
|
capBuilder.addMatchingUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION);
|
||||||
.build();
|
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)
|
.setAudioFormat(format)
|
||||||
.setBufferSizeInBytes(bufferSize)
|
.setBufferSizeInBytes(bufferSize)
|
||||||
.setAudioPlaybackCaptureConfig(capConfig)
|
.setAudioPlaybackCaptureConfig(capBuilder.build())
|
||||||
.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) {
|
private AudioRecord buildMicRecord() {
|
||||||
byte[] buf = new byte[4096];
|
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();
|
long startNs = System.nanoTime();
|
||||||
while (running) {
|
while (running) {
|
||||||
int read = record.read(buf, 0, buf.length);
|
int pRead = playbackRecord.read(playBuf, 0, playBuf.length);
|
||||||
if (read > 0) {
|
int mRead = micRecord != null ? micRecord.read(micBuf, 0, micBuf.length) : 0;
|
||||||
long ptsUs = (System.nanoTime() - startNs) / 1000;
|
if (pRead < 0 && mRead < 0) {
|
||||||
callback.onPcm(buf, read, ptsUs);
|
Log.w(TAG, "Audio read error");
|
||||||
} else if (read < 0) {
|
continue;
|
||||||
Log.w(TAG, "Audio read error: " + read);
|
}
|
||||||
|
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() {
|
public void stop() {
|
||||||
running = false;
|
running = false;
|
||||||
if (thread != null) {
|
if (thread != null) {
|
||||||
@@ -86,14 +200,31 @@ public class AudioCapture {
|
|||||||
} catch (InterruptedException ignored) {
|
} catch (InterruptedException ignored) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
}
|
}
|
||||||
|
thread = null;
|
||||||
}
|
}
|
||||||
if (record != null) {
|
releaseMic();
|
||||||
|
releasePlayback();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void releasePlayback() {
|
||||||
|
if (playbackRecord != null) {
|
||||||
try {
|
try {
|
||||||
record.stop();
|
playbackRecord.stop();
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
record.release();
|
playbackRecord.release();
|
||||||
record = null;
|
playbackRecord = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void releaseMic() {
|
||||||
|
if (micRecord != null) {
|
||||||
|
try {
|
||||||
|
micRecord.stop();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
micRecord.release();
|
||||||
|
micRecord = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,26 @@ public class AudioEncoder {
|
|||||||
running = true;
|
running = true;
|
||||||
drainThread = new Thread(this::drainLoop, "AudioEncoderDrain");
|
drainThread = new Thread(this::drainLoop, "AudioEncoderDrain");
|
||||||
drainThread.start();
|
drainThread.start();
|
||||||
|
startSilencePrimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feeds silent PCM so AAC config is emitted even when playback capture is idle. */
|
||||||
|
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) {
|
public void queuePcm(byte[] pcm, int length, long ptsUs) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -117,28 +117,28 @@ public final class CastSendPump {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class Outgoing {
|
static final class Outgoing {
|
||||||
final byte type;
|
final byte type;
|
||||||
final byte[] payload;
|
final byte[] payload;
|
||||||
final long ptsUs;
|
final long ptsUs;
|
||||||
final boolean keyFrame;
|
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.type = type;
|
||||||
this.payload = payload;
|
this.payload = payload;
|
||||||
this.ptsUs = ptsUs;
|
this.ptsUs = ptsUs;
|
||||||
this.keyFrame = keyFrame;
|
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);
|
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);
|
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) {
|
if (type != CastProtocol.MSG_VIDEO_FRAME) {
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,79 +12,108 @@ import com.foxx.androidcast.R;
|
|||||||
import com.foxx.androidcast.discovery.DiscoveryManager;
|
import com.foxx.androidcast.discovery.DiscoveryManager;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
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 {
|
public class DeviceListAdapter extends BaseAdapter {
|
||||||
private final LayoutInflater inflater;
|
private final LayoutInflater inflater;
|
||||||
private final List<DiscoveryManager.DiscoveredDevice> devices = new ArrayList<>();
|
private final boolean multiSelect;
|
||||||
private int selectedPosition = -1;
|
private final List<DiscoveryManager.DiscoveredDevice> devices = new ArrayList<>();
|
||||||
|
private int selectedPosition = -1;
|
||||||
|
private final Set<Integer> selectedPositions = new LinkedHashSet<>();
|
||||||
|
|
||||||
public DeviceListAdapter(Context context) {
|
public DeviceListAdapter(Context context, boolean multiSelect) {
|
||||||
inflater = LayoutInflater.from(context);
|
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);
|
||||||
}
|
}
|
||||||
|
if (selectedPosition >= devices.size()) {
|
||||||
public void clearDevices() {
|
selectedPosition = -1;
|
||||||
devices.clear();
|
|
||||||
selectedPosition = -1;
|
|
||||||
notifyDataSetChanged();
|
|
||||||
}
|
}
|
||||||
|
selectedPositions.removeIf(i -> i >= devices.size());
|
||||||
|
notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
|
||||||
public void setDevices(List<DiscoveryManager.DiscoveredDevice> list) {
|
public void setSelectedPosition(int position) {
|
||||||
devices.clear();
|
if (multiSelect) {
|
||||||
if (list != null) {
|
if (position >= 0 && position < devices.size()) {
|
||||||
devices.addAll(list);
|
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) {
|
public DiscoveryManager.DiscoveredDevice getSelectedDevice() {
|
||||||
selectedPosition = position;
|
List<DiscoveryManager.DiscoveredDevice> list = getSelectedDevices();
|
||||||
notifyDataSetChanged();
|
return list.isEmpty() ? null : list.get(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getSelectedPosition() {
|
@Override
|
||||||
return selectedPosition;
|
public int getCount() {
|
||||||
}
|
return devices.size();
|
||||||
|
}
|
||||||
|
|
||||||
public DiscoveryManager.DiscoveredDevice getSelectedDevice() {
|
@Override
|
||||||
if (selectedPosition < 0 || selectedPosition >= devices.size()) {
|
public DiscoveryManager.DiscoveredDevice getItem(int position) {
|
||||||
return null;
|
return devices.get(position);
|
||||||
}
|
}
|
||||||
return devices.get(selectedPosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int getCount() {
|
public long getItemId(int position) {
|
||||||
return devices.size();
|
return position;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public DiscoveryManager.DiscoveredDevice getItem(int position) {
|
public View getView(int position, View convertView, ViewGroup parent) {
|
||||||
return devices.get(position);
|
View row = convertView;
|
||||||
}
|
if (row == null) {
|
||||||
|
row = inflater.inflate(R.layout.item_device, parent, false);
|
||||||
@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;
|
|
||||||
}
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import android.util.Log;
|
|||||||
import android.view.Surface;
|
import android.view.Surface;
|
||||||
import android.view.WindowManager;
|
import android.view.WindowManager;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.CastActiveState;
|
||||||
import com.foxx.androidcast.CastNotifications;
|
import com.foxx.androidcast.CastNotifications;
|
||||||
import com.foxx.androidcast.CastConfig;
|
import com.foxx.androidcast.CastConfig;
|
||||||
import com.foxx.androidcast.CastResolution;
|
import com.foxx.androidcast.CastResolution;
|
||||||
@@ -37,6 +38,8 @@ import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
|||||||
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
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_PORT = "port";
|
||||||
public static final String EXTRA_PIN = "pin";
|
public static final String EXTRA_PIN = "pin";
|
||||||
public static final String EXTRA_DEVICE_NAME = "device_name";
|
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";
|
public static final String ACTION_STOP = "com.foxx.androidcast.STOP_SEND";
|
||||||
|
|
||||||
private final RemoteCallbackList<ICastStatusCallback> callbacks = new RemoteCallbackList<>();
|
private final RemoteCallbackList<ICastStatusCallback> callbacks = new RemoteCallbackList<>();
|
||||||
@@ -68,10 +72,15 @@ public class ScreenCastService extends Service {
|
|||||||
private CastTuningEngine.EffectiveParams effectiveParams;
|
private CastTuningEngine.EffectiveParams effectiveParams;
|
||||||
private long lastSignificantFrameMs;
|
private long lastSignificantFrameMs;
|
||||||
private final SenderStreamMetrics senderMetrics = new SenderStreamMetrics();
|
private final SenderStreamMetrics senderMetrics = new SenderStreamMetrics();
|
||||||
|
private final SenderAudioMetrics senderAudioMetrics = new SenderAudioMetrics();
|
||||||
private AudioEncoder audioEncoder;
|
private AudioEncoder audioEncoder;
|
||||||
private AudioCapture audioCapture;
|
private AudioCapture audioCapture;
|
||||||
|
private long audioFramesSent;
|
||||||
|
private volatile String audioCaptureMode = "playback";
|
||||||
private CastSession session;
|
private CastSession session;
|
||||||
private CastSendPump sendPump;
|
private CastSendPump sendPump;
|
||||||
|
private CastFanoutPump fanoutPump;
|
||||||
|
private MultiCastCoordinator multiCoordinator;
|
||||||
private NetworkFeedbackManager networkFeedback;
|
private NetworkFeedbackManager networkFeedback;
|
||||||
private PowerManager.WakeLock wakeLock;
|
private PowerManager.WakeLock wakeLock;
|
||||||
private Thread ioThread;
|
private Thread ioThread;
|
||||||
@@ -131,7 +140,9 @@ public class ScreenCastService extends Service {
|
|||||||
if (casting.get()) {
|
if (casting.get()) {
|
||||||
return START_STICKY;
|
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 = new Thread(() -> runCast(intent), "ScreenCastIO");
|
||||||
ioThread.start();
|
ioThread.start();
|
||||||
return START_STICKY;
|
return START_STICKY;
|
||||||
@@ -161,20 +172,43 @@ public class ScreenCastService extends Service {
|
|||||||
|
|
||||||
acquireWakeLock();
|
acquireWakeLock();
|
||||||
try {
|
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));
|
updateStatus(getString(R.string.connecting_to, host));
|
||||||
session = new CastSession(CastTransportFactory.create(settings));
|
multiCoordinator.connectAndHandshake(targets, settings,
|
||||||
updateStatus(getString(R.string.waiting_receiver, host, port));
|
deviceName != null ? deviceName : "Sender", pin);
|
||||||
connectWithRetry(host, port, settings);
|
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));
|
updateStatus(getString(R.string.authenticating));
|
||||||
CastSession.HandshakeResult handshake = session.clientHandshake(
|
|
||||||
deviceName != null ? deviceName : "Sender", pin, settings);
|
|
||||||
settings = handshake.settings;
|
|
||||||
networkFeedback = new NetworkFeedbackManager(session, true);
|
networkFeedback = new NetworkFeedbackManager(session, true);
|
||||||
PassThroughNetworkControlPlane plane =
|
PassThroughNetworkControlPlane plane =
|
||||||
(PassThroughNetworkControlPlane) networkFeedback.getPlane();
|
(PassThroughNetworkControlPlane) networkFeedback.getPlane();
|
||||||
sendPump = new CastSendPump();
|
plane.setStatsEnricher(this::enrichSenderStats);
|
||||||
sendPump.start(session, () -> stopping.set(true), plane);
|
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);
|
casting.set(true);
|
||||||
|
CastActiveState.setSenderCasting(true);
|
||||||
activeSettings = settings;
|
activeSettings = settings;
|
||||||
startCaptureOnMainThread(resultCode, new Intent(data), settings);
|
startCaptureOnMainThread(resultCode, new Intent(data), settings);
|
||||||
scheduleTuningTick();
|
scheduleTuningTick();
|
||||||
@@ -182,7 +216,9 @@ public class ScreenCastService extends Service {
|
|||||||
try {
|
try {
|
||||||
if (networkFeedback != null) {
|
if (networkFeedback != null) {
|
||||||
networkFeedback.tick();
|
networkFeedback.tick();
|
||||||
CastProtocol.Message msg = session.receive(100);
|
CastProtocol.Message msg = multiCoordinator != null
|
||||||
|
? multiCoordinator.pollReceive(100)
|
||||||
|
: session.receive(100);
|
||||||
if (msg != null) {
|
if (msg != null) {
|
||||||
if (networkFeedback.handleMessage(msg)) {
|
if (networkFeedback.handleMessage(msg)) {
|
||||||
networkFeedback.applyAdvice(networkFeedback.getPlane().getOutboundAdvice());
|
networkFeedback.applyAdvice(networkFeedback.getPlane().getOutboundAdvice());
|
||||||
@@ -352,7 +388,7 @@ public class ScreenCastService extends Service {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
|
public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
|
||||||
if (sendPump == null || frameData == null) {
|
if ((sendPump == null && fanoutPump == null) || frameData == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
boolean tiny = effectiveParams != null && effectiveParams.skipTinyPFrames
|
boolean tiny = effectiveParams != null && effectiveParams.skipTinyPFrames
|
||||||
@@ -363,7 +399,7 @@ public class ScreenCastService extends Service {
|
|||||||
}
|
}
|
||||||
lastSignificantFrameMs = System.currentTimeMillis();
|
lastSignificantFrameMs = System.currentTimeMillis();
|
||||||
senderMetrics.onEncodedFrame(keyFrame);
|
senderMetrics.onEncodedFrame(keyFrame);
|
||||||
sendPump.enqueueVideo(ptsUs, keyFrame, frameData);
|
enqueueVideoFrame(ptsUs, keyFrame, frameData);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -439,6 +475,40 @@ public class ScreenCastService extends Service {
|
|||||||
return null;
|
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() {
|
private void scheduleTuningTick() {
|
||||||
mainHandler.postDelayed(() -> {
|
mainHandler.postDelayed(() -> {
|
||||||
if (!casting.get() || stopping.get() || activeSettings == null) {
|
if (!casting.get() || stopping.get() || activeSettings == null) {
|
||||||
@@ -465,7 +535,9 @@ public class ScreenCastService extends Service {
|
|||||||
effectiveParams = next;
|
effectiveParams = next;
|
||||||
maybeAdaptCaptureSize(peer);
|
maybeAdaptCaptureSize(peer);
|
||||||
String mime = activeSettings != null ? activeSettings.getNegotiatedVideoMime() : null;
|
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();
|
scheduleTuningTick();
|
||||||
}, 2_000);
|
}, 2_000);
|
||||||
}
|
}
|
||||||
@@ -528,17 +600,32 @@ public class ScreenCastService extends Service {
|
|||||||
if (frameData == null || frameData.length == 0) {
|
if (frameData == null || frameData.length == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
audioFramesSent++;
|
||||||
sendSafe(CastProtocol.MSG_AUDIO_FRAME,
|
sendSafe(CastProtocol.MSG_AUDIO_FRAME,
|
||||||
() -> CastProtocol.audioFramePayload(ptsUs, frameData));
|
() -> CastProtocol.audioFramePayload(ptsUs, frameData));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
audioCapture = new AudioCapture();
|
audioFramesSent = 0;
|
||||||
audioCapture.start(projection, (pcm, length, ptsUs) -> {
|
senderAudioMetrics.reset();
|
||||||
if (audioEncoder != null) {
|
audioCaptureMode = "playback";
|
||||||
audioEncoder.queuePcm(pcm, length, ptsUs);
|
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) {
|
} catch (IOException e) {
|
||||||
Log.e(TAG, "Audio pipeline failed", 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) {
|
private void sendSafe(byte type, PayloadBuilder builder) {
|
||||||
if (stopping.get() || sendPump == null) {
|
if (stopping.get() || (sendPump == null && fanoutPump == null)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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) {
|
} catch (IOException e) {
|
||||||
if (!stopping.get()) {
|
if (!stopping.get()) {
|
||||||
Log.w(TAG, "Payload build failed: " + e.getMessage());
|
Log.w(TAG, "Payload build failed: " + e.getMessage());
|
||||||
@@ -597,6 +697,7 @@ public class ScreenCastService extends Service {
|
|||||||
private void releaseAll() {
|
private void releaseAll() {
|
||||||
stopping.set(true);
|
stopping.set(true);
|
||||||
casting.set(false);
|
casting.set(false);
|
||||||
|
CastActiveState.setSenderCasting(false);
|
||||||
unregisterDisplayListener();
|
unregisterDisplayListener();
|
||||||
networkFeedback = null;
|
networkFeedback = null;
|
||||||
if (virtualDisplay != null) {
|
if (virtualDisplay != null) {
|
||||||
@@ -623,11 +724,10 @@ public class ScreenCastService extends Service {
|
|||||||
projection.stop();
|
projection.stop();
|
||||||
projection = null;
|
projection = null;
|
||||||
}
|
}
|
||||||
if (sendPump != null) {
|
if (multiCoordinator != null) {
|
||||||
sendPump.stop();
|
multiCoordinator.closeAll();
|
||||||
sendPump = null;
|
multiCoordinator = null;
|
||||||
}
|
} else if (session != null) {
|
||||||
if (session != null) {
|
|
||||||
try {
|
try {
|
||||||
session.send(CastProtocol.MSG_GOODBYE, new byte[0]);
|
session.send(CastProtocol.MSG_GOODBYE, new byte[0]);
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
@@ -635,6 +735,14 @@ public class ScreenCastService extends Service {
|
|||||||
session.close();
|
session.close();
|
||||||
session = null;
|
session = null;
|
||||||
}
|
}
|
||||||
|
if (fanoutPump != null) {
|
||||||
|
fanoutPump.stop();
|
||||||
|
fanoutPump = null;
|
||||||
|
}
|
||||||
|
if (sendPump != null) {
|
||||||
|
sendPump.stop();
|
||||||
|
sendPump = null;
|
||||||
|
}
|
||||||
releaseWakeLock();
|
releaseWakeLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,11 +758,14 @@ public class ScreenCastService extends Service {
|
|||||||
stopSelf();
|
stopSelf();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startSenderForeground(String text) {
|
private void startSenderForeground(String text, boolean withAudio) {
|
||||||
android.app.Notification n = CastNotifications.sender(this, text, ScreenCastService.class);
|
android.app.Notification n = CastNotifications.sender(this, text, ScreenCastService.class);
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
startForeground(CastNotifications.ID_SENDER, n,
|
int fgsType = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION;
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION);
|
if (withAudio) {
|
||||||
|
fgsType |= ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE;
|
||||||
|
}
|
||||||
|
startForeground(CastNotifications.ID_SENDER, n, fgsType);
|
||||||
} else {
|
} else {
|
||||||
startForeground(CastNotifications.ID_SENDER, n);
|
startForeground(CastNotifications.ID_SENDER, n);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package com.foxx.androidcast.sender;
|
|||||||
|
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
import android.content.Intent;
|
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.media.projection.MediaProjectionManager;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
@@ -11,16 +14,18 @@ import android.provider.Settings;
|
|||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.WindowManager;
|
import android.view.WindowManager;
|
||||||
import android.widget.Button;
|
|
||||||
import android.widget.EditText;
|
import android.widget.EditText;
|
||||||
import android.widget.ListView;
|
import android.widget.ListView;
|
||||||
import android.widget.ProgressBar;
|
import android.widget.ProgressBar;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.activity.OnBackPressedCallback;
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.CastActiveState;
|
||||||
import com.foxx.androidcast.CastConfig;
|
import com.foxx.androidcast.CastConfig;
|
||||||
|
import com.foxx.androidcast.sender.CastReceiverTarget;
|
||||||
import com.foxx.androidcast.CastSettings;
|
import com.foxx.androidcast.CastSettings;
|
||||||
import com.foxx.androidcast.PermissionHelper;
|
import com.foxx.androidcast.PermissionHelper;
|
||||||
import com.foxx.androidcast.PlatformCastSupport;
|
import com.foxx.androidcast.PlatformCastSupport;
|
||||||
@@ -31,6 +36,7 @@ import com.foxx.androidcast.discovery.DiscoveryManager;
|
|||||||
public class SenderActivity extends AppCompatActivity {
|
public class SenderActivity extends AppCompatActivity {
|
||||||
private static final int REQUEST_MEDIA_PROJECTION = 2001;
|
private static final int REQUEST_MEDIA_PROJECTION = 2001;
|
||||||
private static final long DISCOVERY_INTERVAL_MS = 30_000;
|
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 static final int DEV_TAP_UNLOCK = 7;
|
||||||
|
|
||||||
private final CastSettings castSettings = new CastSettings();
|
private final CastSettings castSettings = new CastSettings();
|
||||||
@@ -49,6 +55,7 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
private int titleTapCount;
|
private int titleTapCount;
|
||||||
private long lastTitleTapMs;
|
private long lastTitleTapMs;
|
||||||
private boolean useManualConnect;
|
private boolean useManualConnect;
|
||||||
|
private long lastBackPressMs;
|
||||||
|
|
||||||
private final Runnable discoveryWatchdog = new Runnable() {
|
private final Runnable discoveryWatchdog = new Runnable() {
|
||||||
@Override
|
@Override
|
||||||
@@ -65,7 +72,7 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
setContentView(R.layout.activity_sender);
|
setContentView(R.layout.activity_sender);
|
||||||
PermissionHelper.request(this);
|
PermissionHelper.request(this);
|
||||||
SettingsUi.bind(this, castSettings);
|
SettingsUi.bindSender(this, castSettings);
|
||||||
|
|
||||||
titleText = findViewById(R.id.text_sender_title);
|
titleText = findViewById(R.id.text_sender_title);
|
||||||
pinInput = findViewById(R.id.edit_pin);
|
pinInput = findViewById(R.id.edit_pin);
|
||||||
@@ -95,30 +102,72 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ListView listView = findViewById(R.id.list_devices);
|
ListView listView = findViewById(R.id.list_devices);
|
||||||
listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
|
listView.setChoiceMode(CastConfig.MULTI_RECEIVER_ENABLED
|
||||||
deviceAdapter = new DeviceListAdapter(this);
|
? ListView.CHOICE_MODE_MULTIPLE : ListView.CHOICE_MODE_SINGLE);
|
||||||
|
deviceAdapter = new DeviceListAdapter(this, CastConfig.MULTI_RECEIVER_ENABLED);
|
||||||
listView.setAdapter(deviceAdapter);
|
listView.setAdapter(deviceAdapter);
|
||||||
listView.setOnItemClickListener((parent, view, position, id) -> {
|
listView.setOnItemClickListener((parent, view, position, id) -> {
|
||||||
deviceAdapter.setSelectedPosition(position);
|
deviceAdapter.setSelectedPosition(position);
|
||||||
useManualConnect = false;
|
useManualConnect = false;
|
||||||
DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice();
|
java.util.List<DiscoveryManager.DiscoveredDevice> selectedList =
|
||||||
if (selected == null) {
|
deviceAdapter.getSelectedDevices();
|
||||||
|
if (selectedList.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!castSettings.getTransport().equals(selected.transport)) {
|
if (!CastConfig.MULTI_RECEIVER_ENABLED) {
|
||||||
Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
|
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 = new DiscoveryManager(this);
|
||||||
discovery.setListener(deviceList -> {
|
discovery.setListener(new DiscoveryManager.Listener() {
|
||||||
discoveryProgress.setVisibility(View.GONE);
|
@Override
|
||||||
deviceAdapter.setDevices(deviceList);
|
public void onDevicesUpdated(java.util.List<DiscoveryManager.DiscoveredDevice> deviceList) {
|
||||||
statusText.setText(deviceList.isEmpty()
|
deviceAdapter.setDevices(deviceList);
|
||||||
? getString(R.string.searching)
|
statusText.setText(deviceList.isEmpty()
|
||||||
: getString(R.string.found_receivers, deviceList.size()));
|
? 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());
|
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
|
@Override
|
||||||
protected void onStart() {
|
protected void onStart() {
|
||||||
super.onStart();
|
super.onStart();
|
||||||
@@ -143,6 +198,7 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
if (discovery != null) {
|
if (discovery != null) {
|
||||||
discovery.stop();
|
discovery.stop();
|
||||||
}
|
}
|
||||||
|
hideDiscoveryProgress();
|
||||||
super.onStop();
|
super.onStop();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +211,6 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
private void restartDiscoveryInternal(boolean userRefresh) {
|
private void restartDiscoveryInternal(boolean userRefresh) {
|
||||||
deviceAdapter.clearDevices();
|
deviceAdapter.clearDevices();
|
||||||
statusText.setText(userRefresh ? R.string.rescanning : R.string.searching);
|
statusText.setText(userRefresh ? R.string.rescanning : R.string.searching);
|
||||||
discoveryProgress.setVisibility(View.VISIBLE);
|
|
||||||
if (discovery != null) {
|
if (discovery != null) {
|
||||||
discovery.stop();
|
discovery.stop();
|
||||||
discovery.clearDevices();
|
discovery.clearDevices();
|
||||||
@@ -163,7 +218,36 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
discovery.startBrowsing();
|
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() {
|
private void startCastFlow() {
|
||||||
|
PermissionHelper.remindIfMissing(this, castSettings.isAudioEnabled());
|
||||||
|
if (!PermissionHelper.hasAll(this)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (castSettings.isPassthrough()) {
|
if (castSettings.isPassthrough()) {
|
||||||
Toast.makeText(this, R.string.codec_passthrough_unavailable, Toast.LENGTH_LONG).show();
|
Toast.makeText(this, R.string.codec_passthrough_unavailable, Toast.LENGTH_LONG).show();
|
||||||
return;
|
return;
|
||||||
@@ -190,29 +274,53 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
pendingDeviceName = host;
|
pendingDeviceName = host;
|
||||||
} else {
|
} else {
|
||||||
DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice();
|
java.util.List<DiscoveryManager.DiscoveredDevice> selectedList =
|
||||||
if (selected == null) {
|
deviceAdapter.getSelectedDevices();
|
||||||
|
if (selectedList.isEmpty()) {
|
||||||
Toast.makeText(this, R.string.select_receiver, Toast.LENGTH_SHORT).show();
|
Toast.makeText(this, R.string.select_receiver, Toast.LENGTH_SHORT).show();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!castSettings.getTransport().equals(selected.transport)) {
|
if (CastConfig.MULTI_RECEIVER_ENABLED && selectedList.size() > CastConfig.MAX_RECEIVERS) {
|
||||||
Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
|
Toast.makeText(this, getString(R.string.too_many_receivers, CastConfig.MAX_RECEIVERS),
|
||||||
|
Toast.LENGTH_LONG).show();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pendingHost = selected.host;
|
pendingTargets = new java.util.ArrayList<>();
|
||||||
pendingPort = selected.port;
|
for (DiscoveryManager.DiscoveredDevice d : selectedList) {
|
||||||
pendingDeviceName = selected.name;
|
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;
|
pendingPin = pin;
|
||||||
MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
|
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 pendingPin;
|
||||||
private String pendingHost;
|
private String pendingHost;
|
||||||
private int pendingPort;
|
private int pendingPort;
|
||||||
private String pendingDeviceName;
|
private String pendingDeviceName;
|
||||||
|
private java.util.ArrayList<CastReceiverTarget> pendingTargets;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
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_PIN, pendingPin);
|
||||||
serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, pendingDeviceName != null ? pendingDeviceName : deviceName);
|
serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, pendingDeviceName != null ? pendingDeviceName : deviceName);
|
||||||
serviceIntent.putExtra(CastSettings.EXTRA, castSettings);
|
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) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
startForegroundService(serviceIntent);
|
startForegroundService(serviceIntent);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,10 @@ public final class SenderStreamMetrics {
|
|||||||
windowFrames++;
|
windowFrames++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public synchronized float getLastSecondFps() {
|
||||||
|
return lastSecondFps;
|
||||||
|
}
|
||||||
|
|
||||||
public synchronized void onNetworkSample(int sendKbps, int rttMs) {
|
public synchronized void onNetworkSample(int sendKbps, int rttMs) {
|
||||||
if (sendKbps > 0) {
|
if (sendKbps > 0) {
|
||||||
lastSendKbps = sendKbps;
|
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;
|
float fps = lastSecondFps > 0 ? lastSecondFps : 0f;
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
if (videoCodec != null && !videoCodec.isEmpty()) {
|
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();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,20 @@ public class VideoEncoder {
|
|||||||
private Thread drainThread;
|
private Thread drainThread;
|
||||||
private String mime;
|
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,
|
public Surface prepare(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
|
||||||
Callback callback) throws IOException {
|
Callback callback) throws IOException {
|
||||||
this.width = width;
|
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() {
|
public void stop() {
|
||||||
running = false;
|
running = false;
|
||||||
if (drainThread != null) {
|
if (drainThread != null) {
|
||||||
|
|||||||
134
app/src/main/java/com/foxx/androidcast/ui/ZoomPanContainer.java
Normal file
134
app/src/main/java/com/foxx/androidcast/ui/ZoomPanContainer.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,19 +4,26 @@
|
|||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="#000000">
|
android:background="#000000">
|
||||||
|
|
||||||
<com.foxx.androidcast.ui.AspectRatioFrameLayout
|
<com.foxx.androidcast.ui.ZoomPanContainer
|
||||||
android:id="@+id/aspect_container"
|
android:id="@+id/zoom_container"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:layout_gravity="center">
|
android:layout_gravity="center">
|
||||||
|
|
||||||
<SurfaceView
|
<com.foxx.androidcast.ui.AspectRatioFrameLayout
|
||||||
android:id="@+id/surface_playback"
|
android:id="@+id/aspect_container"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center">
|
||||||
android:keepScreenOn="true" />
|
|
||||||
</com.foxx.androidcast.ui.AspectRatioFrameLayout>
|
<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
|
<include
|
||||||
layout="@layout/overlay_stream_awaiting"
|
layout="@layout/overlay_stream_awaiting"
|
||||||
|
|||||||
@@ -106,8 +106,11 @@
|
|||||||
android:id="@+id/progress_discovery"
|
android:id="@+id/progress_discovery"
|
||||||
style="?android:attr/progressBarStyleHorizontal"
|
style="?android:attr/progressBarStyleHorizontal"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="4dp"
|
android:layout_height="match_parent"
|
||||||
android:layout_gravity="bottom"
|
android:layout_gravity="center"
|
||||||
|
android:alpha="0.85"
|
||||||
|
android:clickable="false"
|
||||||
|
android:focusable="false"
|
||||||
android:indeterminate="true"
|
android:indeterminate="true"
|
||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
|||||||
@@ -88,6 +88,25 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content" />
|
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
|
<CheckBox
|
||||||
android:id="@+id/checkbox_audio"
|
android:id="@+id/checkbox_audio"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -15,6 +15,11 @@
|
|||||||
<string name="label_transport">Transport</string>
|
<string name="label_transport">Transport</string>
|
||||||
<string name="label_quality">Quality (bitrate / FPS)</string>
|
<string name="label_quality">Quality (bitrate / FPS)</string>
|
||||||
<string name="label_resolution">Capture resolution</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">Include audio (playback capture, Android 10+)</string>
|
||||||
<string name="label_audio_unavailable">Audio unavailable (requires Android 10+)</string>
|
<string name="label_audio_unavailable">Audio unavailable (requires Android 10+)</string>
|
||||||
<string name="audio_unavailable_title">Video only</string>
|
<string name="audio_unavailable_title">Video only</string>
|
||||||
@@ -52,6 +57,7 @@
|
|||||||
<string name="select_receiver">Select a receiver first</string>
|
<string name="select_receiver">Select a receiver first</string>
|
||||||
<string name="enter_pin">Enter PIN</string>
|
<string name="enter_pin">Enter PIN</string>
|
||||||
<string name="capture_denied">Screen capture permission denied</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="casting_started">Casting started</string>
|
||||||
<string name="found_receivers">Found %1$d receiver(s)</string>
|
<string name="found_receivers">Found %1$d receiver(s)</string>
|
||||||
<string name="receiver_ready">Ready — PIN %1$s · %2$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_listening">Listening for cast — awaiting sender…</string>
|
||||||
<string name="playback_awaiting_stream">Awaiting for stream to come…</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_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_starting">Starting receiver service…</string>
|
||||||
<string name="receiver_listening">Listening for cast</string>
|
<string name="receiver_listening">Listening for cast</string>
|
||||||
<string name="waiting_surface">Connected — open video surface…</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_opened">Fullscreen playback %1$dx%2$d</string>
|
||||||
<string name="playback_rendering">Video playing</string>
|
<string name="playback_rendering">Video playing</string>
|
||||||
<string name="device_selected">Selected: %1$s (%2$s, %3$s)</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_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_message">The sender is streaming audio. Play it on this device?</string>
|
||||||
<string name="audio_consent_yes">Play audio</string>
|
<string name="audio_consent_yes">Play audio</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user