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

proto changes, 1:m initial

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

View File

@@ -10,6 +10,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.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"

View File

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

View File

@@ -3,8 +3,15 @@ package com.foxx.androidcast;
/** Shared constants for the LAN cast POC. */ /** 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";

View File

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

View File

@@ -0,0 +1,128 @@
package com.foxx.androidcast;
import android.content.Context;
import android.content.SharedPreferences;
import android.media.MediaDrm;
import android.os.Build;
import android.provider.Settings;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Locale;
import java.util.UUID;
/**
* Stable device identity for cast handshakes. Survives app reinstall when possible
* (Widevine ID + per-app ANDROID_ID hash). Cached in {@link SharedPreferences}.
*/
public final class DeviceInfo {
private static final String PREFS = "androidcast_device";
private static final String KEY_UUID = "device_uuid";
private static final UUID WIDEVINE_UUID =
UUID.fromString("edef8ba9-79d6-4ace-a3c8-27dcd51d21ed");
private static volatile String cachedUuid;
private DeviceInfo() {}
/** Returns a stable UUID string for this device (app-singleton). */
public static String getDeviceUuid(Context context) {
if (cachedUuid != null) {
return cachedUuid;
}
synchronized (DeviceInfo.class) {
if (cachedUuid != null) {
return cachedUuid;
}
Context app = context.getApplicationContext();
SharedPreferences prefs = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
String stored = prefs.getString(KEY_UUID, null);
if (stored != null && !stored.isEmpty()) {
cachedUuid = stored;
return cachedUuid;
}
String generated = generateStableUuid(app);
prefs.edit().putString(KEY_UUID, generated).apply();
cachedUuid = generated;
return cachedUuid;
}
}
/** Lower 32 bits of hashed UUID for {@code placeholder_id} in proto headers. */
public static int getPlaceholderId(Context context) {
return (int) (hashUuid(getDeviceUuid(context)) & 0xFFFFFFFFL);
}
private static String generateStableUuid(Context context) {
StringBuilder material = new StringBuilder();
material.append("androidcast:");
String androidId = Settings.Secure.getString(
context.getContentResolver(), Settings.Secure.ANDROID_ID);
if (androidId != null && !androidId.isEmpty()) {
material.append(androidId);
}
byte[] widevine = readWidevineId();
if (widevine != null && widevine.length > 0) {
material.append(':');
for (byte b : widevine) {
material.append(String.format(Locale.US, "%02x", b));
}
}
material.append(':').append(Build.BOARD).append(':').append(Build.DEVICE);
return uuidFromSha256(material.toString());
}
private static byte[] readWidevineId() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) {
return null;
}
MediaDrm drm = null;
try {
drm = new MediaDrm(WIDEVINE_UUID);
return drm.getPropertyByteArray(MediaDrm.PROPERTY_DEVICE_UNIQUE_ID);
} catch (Exception ignored) {
return null;
} finally {
if (drm != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
drm.close();
} else {
drm.release();
}
}
}
}
private static String uuidFromSha256(String material) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(material.getBytes(StandardCharsets.UTF_8));
long msb = 0;
long lsb = 0;
for (int i = 0; i < 8; i++) {
msb = (msb << 8) | (digest[i] & 0xff);
}
for (int i = 8; i < 16; i++) {
lsb = (lsb << 8) | (digest[i] & 0xff);
}
return new UUID(msb, lsb).toString();
} catch (NoSuchAlgorithmException e) {
return UUID.randomUUID().toString();
}
}
private static long hashUuid(String uuid) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(uuid.getBytes(StandardCharsets.UTF_8));
long h = 0;
for (int i = 0; i < 8; i++) {
h = (h << 8) | (digest[i] & 0xff);
}
return h;
} catch (NoSuchAlgorithmException e) {
return uuid.hashCode();
}
}
}

View File

@@ -3,6 +3,10 @@ package com.foxx.androidcast;
import android.content.Intent; import android.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);
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,18 @@
package com.foxx.androidcast;
/**
* Application and protocol version strings (CI may replace via script).
* App and proto versions are independent.
*/
public final class VersionInfo {
/** Human-readable app release (default until CI injects). */
public static final String APP_VERSION_STRING = "00.00.00.0000";
/** Human-readable wire protocol / header version. */
public static final String PROTO_VERSION_STRING = "00.01.00.0001";
public static final long APP_VERSION = VersionUtils.parseToInt(APP_VERSION_STRING);
public static final long PROTO_VERSION = VersionUtils.parseToInt(PROTO_VERSION_STRING);
private VersionInfo() {}
}

View File

@@ -0,0 +1,64 @@
package com.foxx.androidcast;
import java.util.Locale;
/**
* Version string format {@code PP.MM.mmmm.BBBB} (e.g. {@code 00.01.0003.5621}) packed into uint64.
* Layout: placeholder(2) · major(2) · minor(4) · build(4) decimal digits.
*/
public final class VersionUtils {
private VersionUtils() {}
public static String format(int placeholder, int major, int minor, int build) {
return String.format(Locale.US, "%02d.%02d.%04d.%04d",
clamp(placeholder, 0, 99),
clamp(major, 0, 99),
clamp(minor, 0, 9999),
clamp(build, 0, 9999));
}
public static long toInt(int placeholder, int major, int minor, int build) {
long ph = clamp(placeholder, 0, 99);
long maj = clamp(major, 0, 99);
long min = clamp(minor, 0, 9999);
long bld = clamp(build, 0, 9999);
return ph * 10_000_000_000L + maj * 100_000_000L + min * 10_000L + bld;
}
public static long parseToInt(String version) {
if (version == null || version.isEmpty()) {
return 0L;
}
String[] parts = version.split("\\.");
int ph = parts.length > 0 ? parsePart(parts[0], 0) : 0;
int maj = parts.length > 1 ? parsePart(parts[1], 0) : 0;
int min = parts.length > 2 ? parsePart(parts[2], 0) : 0;
int bld = parts.length > 3 ? parsePart(parts[3], 0) : 0;
return toInt(ph, maj, min, bld);
}
public static String formatFromInt(long packed) {
if (packed < 0) {
packed = 0;
}
int build = (int) (packed % 10_000L);
packed /= 10_000L;
int minor = (int) (packed % 10_000L);
packed /= 10_000L;
int major = (int) (packed % 100L);
int placeholder = (int) (packed / 100L);
return format(placeholder, major, minor, build);
}
private static int parsePart(String s, int fallback) {
try {
return Integer.parseInt(s.trim());
} catch (NumberFormatException e) {
return fallback;
}
}
private static int clamp(int v, int min, int max) {
return Math.max(min, Math.min(max, v));
}
}

View File

@@ -0,0 +1,322 @@
package com.foxx.androidcast.diagnostics;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.CastTuningEngine;
import com.foxx.androidcast.media.CodecNegotiator;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.receiver.StreamMetrics;
import java.util.ArrayList;
import java.util.List;
/** Builds HTML diagnostics overlay with red highlights for out-of-range metrics. */
public final class CastDiagnosticsFormatter {
private static final String COLOR_OK = "#E0FFE0";
private static final String COLOR_WARN = "#FF6666";
private static final String COLOR_DIM = "#AAAAAA";
private CastDiagnosticsFormatter() {}
public static String formatHtml(StreamMetrics metrics, String transport, String videoCodec,
int streamW, int streamH, int renderW, int renderH, boolean idle,
CastSettings localSettings, CastSettings remoteSettings,
NetworkStatsSnapshot peer, NetworkStatsSnapshot lastLocal, int viewZoomPercent) {
float inFps = metrics.getInFps();
float renderFps = metrics.getRenderFps();
int bwInKbps = metrics.getBwInKbps();
long videoPackets = metrics.getVideoPackets();
long audioPackets = metrics.getAudioPackets();
long audioRendered = metrics.getAudioRenderedWrites();
long audioPcmSilent = metrics.getAudioPcmSilentBlocks();
long audioPcmAudible = metrics.getAudioPcmAudibleBlocks();
int audioVu = metrics.getAudioVuPercent();
float audioPeakDb = metrics.getAudioLastPeakDb();
float audioRmsDb = metrics.getAudioLastRmsDb();
long keyFrames = metrics.getKeyFrames();
long deltaFrames = metrics.getDeltaFrames();
int avgKb = metrics.getAvgKbPerFrame();
long idleMs = metrics.getLastFrameIdleMs();
int rttMs = metrics.getPeerRttMs();
CastSettings tuneSettings = remoteSettings != null ? remoteSettings : localSettings;
CastTuningEngine.EffectiveParams eff = CastTuningEngine.resolve(tuneSettings, peer);
int suggestedBwKbps = eff.videoBitrate / 1000;
int targetEncKbps = eff.videoBitrate / 1000;
int congestionLevel = 0;
float peerEncodeFps = 0f;
int peerSendKbps = metrics.getPeerSendKbps();
int peerRecvKbps = metrics.getPeerRecvKbps();
int peerQueue = 0;
int peerDroppedP = 0;
long peerAudioFrames = 0;
boolean peerAudioActive = false;
int peerCaptureVu = 0;
long peerCaptureSilent = 0;
long peerCaptureAudible = 0;
float peerCapturePeakDb = -96f;
float peerCaptureRmsDb = -96f;
if (peer != null) {
if (peer.rttMs > 0) {
rttMs = peer.rttMs;
}
peerSendKbps = peer.sendBitrateKbps;
peerRecvKbps = peer.recvBitrateKbps;
peerEncodeFps = peer.encodeFps;
peerQueue = peer.encodeQueueDepth;
peerDroppedP = peer.droppedPFrames;
peerAudioFrames = peer.audioFramesSent;
peerAudioActive = peer.audioActive;
congestionLevel = peer.congestionLevel;
if (peer.suggestedBitrateKbps > 0) {
suggestedBwKbps = peer.suggestedBitrateKbps;
}
if (peer.targetBitrateKbps > 0) {
targetEncKbps = peer.targetBitrateKbps;
}
peerCaptureVu = peer.audioCaptureVuPercent;
peerCaptureSilent = peer.audioCaptureSilentBlocks;
peerCaptureAudible = peer.audioCaptureAudibleBlocks;
peerCapturePeakDb = peer.audioCapturePeakDb;
peerCaptureRmsDb = peer.audioCaptureRmsDb;
}
if (lastLocal != null) {
congestionLevel = Math.max(congestionLevel, lastLocal.congestionLevel);
}
boolean audioRequested = remoteSettings != null && remoteSettings.isAudioRequested();
List<String> hints = buildHints(inFps, renderFps, bwInKbps, rttMs, videoPackets);
int quality = CastDiagnosticsScorer.compute(inFps, renderFps, bwInKbps, suggestedBwKbps,
rttMs, congestionLevel, audioPackets, audioRequested);
String qualityColor = quality >= 70 ? COLOR_OK : (quality >= 45 ? "#FFCC66" : COLOR_WARN);
String bitrateMode = CastDiagnosticsLabels.bitrateMode(
localSettings != null ? localSettings.getBitrateMode() : CastSettings.BitrateMode.AUTO);
String netAdopt = CastDiagnosticsLabels.networkAdoption(
localSettings != null ? localSettings.getNetworkAdoption() : CastSettings.NetworkAdoption.AUTO);
String ctrl = CastDiagnosticsLabels.congestionController();
StringBuilder html = new StringBuilder();
html.append("<font color='").append(COLOR_DIM).append("'>Transport: ")
.append(escape(transport != null ? transport.toUpperCase() : "?"))
.append("</font><br/>");
if (videoCodec != null && !videoCodec.isEmpty()) {
html.append("Codec: <font color='").append(COLOR_OK).append("'>")
.append(escape(CodecNegotiator.displayName(videoCodec))).append("</font><br/>");
}
html.append("Stream: ").append(streamW).append('x').append(streamH);
if (viewZoomPercent != 0) {
html.append(" (").append(viewZoomPercent > 0 ? "+" : "").append(viewZoomPercent).append("%)");
}
if (renderW > 0 && renderH > 0 && (renderW != streamW || renderH != streamH)) {
html.append(" render: ").append(renderW).append('x').append(renderH);
}
html.append("<br/>");
html.append("In FPS: ").append(colored(formatFloat(inFps), inFps > 0 && inFps < 15f));
html.append(" Render FPS: ").append(colored(formatFloat(renderFps),
inFps > 8f && renderFps > 0 && renderFps < inFps * 0.75f));
html.append("<br/>");
html.append("BW in: ").append(colored(bwInKbps > 0 ? bwInKbps + " kbps" : "n/a",
suggestedBwKbps > 0 && bwInKbps > 0 && bwInKbps < suggestedBwKbps * 0.7f));
html.append(" RTT: ").append(colored(rttMs > 0 ? rttMs + " ms" : "n/a", rttMs > 350));
html.append("<br/>");
html.append("Suggested BW: ").append(colored(
suggestedBwKbps > 0 ? suggestedBwKbps + " kbps" : "n/a",
bwInKbps > 0 && suggestedBwKbps > 0 && bwInKbps < suggestedBwKbps * 0.85f));
html.append(" Target enc: ").append(colored(
targetEncKbps > 0 ? targetEncKbps + " kbps" : "n/a", false));
html.append("<br/>");
html.append("Congestion: ").append(colored(String.valueOf(congestionLevel), congestionLevel > 0));
html.append("<br/>");
html.append("Ctrl: ").append(escape(ctrl));
html.append("<br/>");
html.append("Mode: ").append(escape(bitrateMode));
html.append("<br/>");
html.append("Net: ").append(escape(netAdopt));
html.append("<br/>");
if (peerSendKbps > 0 || peerRecvKbps > 0 || peerEncodeFps > 0) {
html.append("<font color='").append(COLOR_DIM).append("'>Sender:</font> ");
if (peerEncodeFps > 0) {
html.append("enc ").append(colored(formatFloat(peerEncodeFps), peerEncodeFps < 12f));
html.append(' ');
}
html.append("tx ").append(peerSendKbps).append(" / rx ").append(peerRecvKbps).append(" kbps");
if (peerQueue > 8 || peerDroppedP > 0) {
html.append(" q").append(peerQueue);
if (peerDroppedP > 0) {
html.append(" dropP=").append(peerDroppedP);
}
}
html.append("<br/>");
if (audioRequested && (peerCaptureSilent > 0 || peerCaptureAudible > 0 || peerCaptureVu > 0)) {
html.append("Sender cap VU: ").append(vuBarHtml(peerCaptureVu)).append(' ')
.append(colored(peerCaptureVu + "%",
peerCaptureVu < 3 && peerCaptureSilent > peerCaptureAudible));
html.append(" peak ").append(formatDb(peerCapturePeakDb))
.append(" rms ").append(formatDb(peerCaptureRmsDb));
html.append("<br/>Sender PCM: silent ")
.append(colored(String.valueOf(peerCaptureSilent),
peerCaptureSilent > 20 && peerCaptureAudible == 0))
.append(" audible ").append(String.valueOf(peerCaptureAudible));
if (peerCaptureSilent > 20 && peerCaptureAudible == 0) {
html.append(" <font color='").append(COLOR_WARN).append("'>")
.append("(sender capture silent)")
.append("</font>");
}
html.append("<br/>");
}
}
if (remoteSettings != null) {
html.append("<font color='").append(COLOR_DIM).append("'>Sender prefs:</font><br/>")
.append(escape(CastDiagnosticsLabels.adjustmentPreset(remoteSettings.getAdjustmentPreset())))
.append("<br/>")
.append(escape(CastDiagnosticsLabels.bitrateMode(remoteSettings.getBitrateMode())))
.append("<br/>")
.append(escape(CastDiagnosticsLabels.resolution(remoteSettings.getResolution())))
.append("<br/>")
.append(escape(CastDiagnosticsLabels.videoCodec(remoteSettings.getVideoCodec())))
.append("<br/>")
.append(escape(CastDiagnosticsLabels.captureMode(remoteSettings.getCaptureMode())));
if (audioRequested) {
html.append(" · audio");
if (peerAudioActive) {
html.append(" (").append(peerAudioFrames).append(" frm)");
}
}
html.append("<br/>");
}
html.append("Video pkts: ").append(videoPackets);
html.append(" (IDR ").append(keyFrames).append(" / P ").append(deltaFrames).append(')');
if (videoPackets > 0) {
html.append(" ~").append(Math.max(1, avgKb)).append(" KB/frame");
}
html.append("<br/>");
html.append("Audio pkts: ").append(colored(String.valueOf(audioPackets),
audioRequested && audioPackets == 0 && videoPackets > 20));
html.append(" rendered: ").append(colored(String.valueOf(audioRendered),
audioRequested && audioPackets > 5 && audioRendered == 0));
if (audioRequested && audioPackets > 0 && audioRendered == 0) {
html.append(" (check receiver volume)");
}
if (audioRequested && audioPackets == 0 && videoPackets > 20) {
html.append(" <font color='").append(COLOR_DIM).append("'>")
.append("(play media on sender)")
.append("</font>");
} else if (audioRequested && audioPackets > 10 && audioRendered == 0) {
html.append(" <font color='").append(COLOR_WARN).append("'>")
.append("(decode/render stalled)")
.append("</font>");
} else if (audioRequested && audioRendered > 15 && audioPcmAudible == 0
&& audioPcmSilent > 15) {
html.append(" <font color='").append(COLOR_WARN).append("'>")
.append("(decoded PCM is silence)")
.append("</font>");
if (peerCaptureSilent > 20 && peerCaptureAudible == 0) {
html.append(" <font color='").append(COLOR_WARN).append("'>")
.append("(fix on sender: Choose app, Android 14+)")
.append("</font>");
}
}
html.append("<br/>");
if (audioRequested && audioRendered > 0) {
html.append("Audio VU: ").append(vuBarHtml(audioVu)).append(' ')
.append(colored(audioVu + "%", audioVu < 3 && audioPcmSilent > audioPcmAudible));
html.append(" peak ").append(formatDb(audioPeakDb))
.append(" rms ").append(formatDb(audioRmsDb));
html.append("<br/>PCM blocks: silent ")
.append(colored(String.valueOf(audioPcmSilent),
audioPcmSilent > 20 && audioPcmAudible == 0))
.append(" audible ").append(String.valueOf(audioPcmAudible));
html.append("<br/>");
}
for (String hint : hints) {
html.append("<font color='").append(COLOR_WARN).append("'>")
.append(escape(hint)).append("</font><br/>");
}
html.append("Quality: <font color='").append(qualityColor).append("'>")
.append(quality).append("").append(CastDiagnosticsScorer.label(quality))
.append("</font><br/>");
html.append("<font color='").append(COLOR_DIM).append("'>State: ")
.append(idle ? "idle (awaiting)" : "streaming");
if (idleMs >= 0) {
html.append(" last frame: ").append(idleMs).append(" ms ago");
}
html.append("</font>");
return html.toString();
}
private static List<String> buildHints(float inFps, float renderFps, int bwInKbps,
int rttMs, long videoPackets) {
List<String> hints = new ArrayList<>();
if (inFps > 0 && inFps < 8f) {
hints.add("Hint: low In FPS → sender encode stall / TCP backlog");
}
if (bwInKbps > 0 && bwInKbps < 350 && inFps < 8f && videoPackets > 30) {
hints.add("Hint: very low BW — static screen or throttled encoder");
}
if (rttMs > 450 && inFps < 15f) {
hints.add("Hint: high RTT — WiFi congestion");
}
if (inFps > 12f && renderFps > 0 && renderFps < inFps * 0.7f) {
hints.add("Hint: decode slower than network (try H.264 or lower res)");
}
return hints;
}
private static String colored(String text, boolean bad) {
return "<font color='" + (bad ? COLOR_WARN : COLOR_OK) + "'>" + escape(text) + "</font>";
}
private static String escape(String s) {
if (s == null) {
return "";
}
return s.replace("&", "&amp;").replace("<", "&lt;");
}
private static String formatFloat(float v) {
if (v <= 0f) {
return "n/a";
}
return String.format(java.util.Locale.US, "%.1f", v);
}
private static String formatDb(float db) {
if (db <= -90f) {
return "-∞";
}
return String.format(java.util.Locale.US, "%.0f dB", db);
}
private static String vuBarHtml(int percent) {
int width = 16;
int filled = Math.min(width, (percent * width + 50) / 100);
StringBuilder b = new StringBuilder("<font color='#555'>[</font>");
for (int i = 0; i < width; i++) {
if (i < filled) {
String c = i < width * 2 / 3 ? "#66FF66" : (i < width * 5 / 6 ? "#FFCC00" : "#FF4444");
b.append("<font color='").append(c).append("'>█</font>");
} else {
b.append("<font color='#333'>░</font>");
}
}
b.append("<font color='#555'>]</font>");
return b.toString();
}
}

View File

@@ -0,0 +1,125 @@
package com.foxx.androidcast.diagnostics;
import com.foxx.androidcast.CastSettings;
/** Human-readable names for diagnostics (no bare AUTO enum tokens). */
public final class CastDiagnosticsLabels {
private CastDiagnosticsLabels() {}
public static String congestionController() {
return "PassThroughNetworkControlPlane (heartbeat stats)";
}
public static String bitrateMode(CastSettings.BitrateMode mode) {
if (mode == null) {
return "CastTuningEngine (default)";
}
switch (mode) {
case NONE:
return "Uncapped encoder bitrate";
case QUALITY:
return "Quality bias (+25% bitrate)";
case SPEED:
return "Speed bias (35% bitrate, ≤24 fps)";
case BALANCED:
return "Balanced preset";
case ROBUST_LESS:
return "RobustLess (UDP, 45% bitrate)";
case ESTIMATED:
return "CastTuningEngine bandwidth estimate";
case AUTO:
default:
return "CastTuningEngine (preset + peer stats)";
}
}
public static String networkAdoption(CastSettings.NetworkAdoption mode) {
if (mode == null) {
return "No extra network scaling";
}
switch (mode) {
case QUALITY:
return "Quality bias (+10% bitrate)";
case SPEED:
return "Speed bias (30% bitrate)";
case ESTIMATED:
return "CastTuningEngine recv-bitrate cap";
case AUTO:
default:
return "No extra network scaling";
}
}
public static String adjustmentPreset(CastSettings.AdjustmentPreset preset) {
if (preset == null) {
return "CastTuningEngine default";
}
switch (preset) {
case NONE:
return "No preset adjustments";
case QUALITY:
return "Quality (+15% bitrate, +2 fps)";
case SPEED:
return "Speed (15% bitrate)";
case PERFORMANCE:
return "Performance (15% bitrate, stable fps)";
case AUTO:
default:
return "CastTuningEngine adaptive preset";
}
}
public static String resolution(CastSettings.Resolution res) {
if (res == null) {
return "Device default";
}
switch (res) {
case FULL:
return "Full display";
case THREE_QUARTERS:
return "75% display scale";
case HALF:
return "50% display scale";
case HD_720P:
return "720p long-edge cap";
case FIXED_1080P:
return "Fixed 1080p box";
case FIXED_720P:
return "Fixed 720p box";
case ADAPTIVE:
return "Adaptive (CastTuningEngine + peer)";
default:
return res.name();
}
}
public static String videoCodec(CastSettings.VideoCodec codec) {
if (codec == null) {
return "CodecNegotiator preference list";
}
switch (codec) {
case H264:
return "H.264 (forced)";
case H265:
return "H.265 (forced)";
case PASSTHROUGH:
return "Passthrough (debug, unimplemented)";
case AUTO:
default:
return "CodecNegotiator (HEVC→AVC→VP9→VP8)";
}
}
public static String captureMode(CastSettings.CaptureMode mode) {
if (mode == null) {
return "Entire screen";
}
switch (mode) {
case USER_CHOICE:
return "Choose app or screen (Android 14+)";
case FULL_SCREEN:
default:
return "Entire screen";
}
}
}

View File

@@ -0,0 +1,62 @@
package com.foxx.androidcast.diagnostics;
/** Heuristic stream quality score (0100) from live metrics. */
public final class CastDiagnosticsScorer {
private CastDiagnosticsScorer() {}
public static int compute(float inFps, float renderFps, int bwInKbps, int suggestedBwKbps,
int rttMs, int congestionLevel, long audioPackets, boolean audioRequested) {
int score = 100;
if (inFps > 0 && inFps < 12f) {
score -= 25;
} else if (inFps > 0 && inFps < 20f) {
score -= 12;
}
if (inFps > 5f && renderFps > 0 && renderFps < inFps * 0.7f) {
score -= 22;
} else if (inFps > 5f && renderFps > 0 && renderFps < inFps * 0.85f) {
score -= 10;
}
if (suggestedBwKbps > 0 && bwInKbps > 0 && bwInKbps < suggestedBwKbps * 0.55f) {
score -= 20;
} else if (suggestedBwKbps > 0 && bwInKbps > 0 && bwInKbps < suggestedBwKbps * 0.75f) {
score -= 10;
}
if (rttMs > 600) {
score -= 18;
} else if (rttMs > 350) {
score -= 8;
}
if (congestionLevel >= 2) {
score -= 15;
} else if (congestionLevel >= 1) {
score -= 6;
}
if (audioRequested && audioPackets == 0) {
score -= 8;
}
if (score < 0) {
return 0;
}
if (score > 100) {
return 100;
}
return score;
}
public static String label(int score) {
if (score >= 85) {
return "Excellent";
}
if (score >= 70) {
return "Good";
}
if (score >= 50) {
return "Fair";
}
if (score >= 30) {
return "Poor";
}
return "Bad";
}
}

View File

@@ -7,6 +7,11 @@ import android.os.Looper;
import android.util.Log; import 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);
try {
sock.receive(packet); sock.receive(packet);
handlePacket(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;

View File

@@ -0,0 +1,60 @@
package com.foxx.androidcast.media;
/** Prepends ADTS sync word so AAC decoders accept encoder access units. */
public final class AacAdtsHelper {
private AacAdtsHelper() {}
public static byte[] wrapIfNeeded(byte[] aacFrame, int sampleRate, int channelCount) {
if (aacFrame == null || aacFrame.length == 0) {
return aacFrame;
}
if (aacFrame.length >= 2 && (aacFrame[0] & 0xFF) == 0xFF && (aacFrame[1] & 0xF0) == 0xF0) {
return aacFrame;
}
int profile = 2;
int freqIdx = sampleRateIndex(sampleRate);
int chanCfg = Math.max(1, Math.min(7, channelCount));
int frameLen = aacFrame.length + 7;
byte[] adts = new byte[frameLen];
adts[0] = (byte) 0xFF;
adts[1] = (byte) 0xF1;
adts[2] = (byte) (((profile - 1) << 6) + (freqIdx << 2) + (chanCfg >> 2));
adts[3] = (byte) (((chanCfg & 3) << 6) + (frameLen >> 11));
adts[4] = (byte) ((frameLen & 0x7FF) >> 3);
adts[5] = (byte) (((frameLen & 7) << 5) + 0x1F);
adts[6] = (byte) 0xFC;
System.arraycopy(aacFrame, 0, adts, 7, aacFrame.length);
return adts;
}
private static int sampleRateIndex(int sampleRate) {
switch (sampleRate) {
case 96000:
return 0;
case 88200:
return 1;
case 64000:
return 2;
case 48000:
return 3;
case 44100:
return 4;
case 32000:
return 5;
case 24000:
return 6;
case 22050:
return 7;
case 16000:
return 8;
case 12000:
return 9;
case 11025:
return 10;
case 8000:
return 11;
default:
return 4;
}
}
}

View File

@@ -0,0 +1,67 @@
package com.foxx.androidcast.media;
/** Peak/RMS analysis for 16-bit little-endian PCM. */
public final class PcmLevelAnalyzer {
/** ~-46 dBFS; below this counts as silence. */
public static final float SILENT_PEAK_THRESHOLD = 0.005f;
public static final class Levels {
public final float peak;
public final float rms;
public final boolean silent;
Levels(float peak, float rms, boolean silent) {
this.peak = peak;
this.rms = rms;
this.silent = silent;
}
public float peakDb() {
if (peak <= 1e-9f) {
return -96f;
}
return (float) (20.0 * Math.log10(peak));
}
}
private PcmLevelAnalyzer() {}
public static Levels analyze(byte[] pcm, int length) {
if (pcm == null || length < 2) {
return new Levels(0f, 0f, true);
}
int samples = length / 2;
long sumSq = 0;
int peakAbs = 0;
for (int i = 0; i + 1 < length; i += 2) {
short s = (short) ((pcm[i] & 0xff) | (pcm[i + 1] << 8));
int a = Math.abs(s);
if (a > peakAbs) {
peakAbs = a;
}
sumSq += (long) s * s;
}
float peak = peakAbs / 32768f;
float rms = samples > 0
? (float) Math.sqrt(sumSq / (double) samples) / 32768f
: 0f;
boolean silent = peak < SILENT_PEAK_THRESHOLD && rms < SILENT_PEAK_THRESHOLD * 0.7f;
return new Levels(peak, rms, silent);
}
/** 0100 visual level from RMS (scaled for speech/music). */
public static int vuPercent(float rms) {
if (rms <= 0f) {
return 0;
}
float db = (float) (20.0 * Math.log10(rms + 1e-9));
float norm = (db + 60f) / 60f;
if (norm < 0f) {
norm = 0f;
}
if (norm > 1f) {
norm = 1f;
}
return Math.round(norm * 100f);
}
}

View File

@@ -0,0 +1,53 @@
package com.foxx.androidcast.network;
import android.media.MediaFormat;
import com.foxx.androidcast.sender.CodecCatalog;
import java.util.List;
/** Video/audio codec bitmasks in {@link CastProtoHeader}. */
public final class CastCodecFlags {
public static final int VIDEO_UNKNOWN = 0;
public static final int VIDEO_H264 = 1;
public static final int VIDEO_H265 = 2;
public static final int VIDEO_VP8 = 4;
public static final int VIDEO_VP9 = 8;
public static final int AUDIO_UNKNOWN = 0;
public static final int AUDIO_PCM = 1;
public static final int AUDIO_AAC = 2;
private CastCodecFlags() {}
public static int videoMaskFromMimes(List<String> mimes) {
int mask = 0;
if (mimes == null) {
return mask;
}
for (String mime : mimes) {
if (MediaFormat.MIMETYPE_VIDEO_AVC.equalsIgnoreCase(mime)) {
mask |= VIDEO_H264;
} else if (MediaFormat.MIMETYPE_VIDEO_HEVC.equalsIgnoreCase(mime)) {
mask |= VIDEO_H265;
} else if (MediaFormat.MIMETYPE_VIDEO_VP8.equalsIgnoreCase(mime)) {
mask |= VIDEO_VP8;
} else if (MediaFormat.MIMETYPE_VIDEO_VP9.equalsIgnoreCase(mime)) {
mask |= VIDEO_VP9;
}
}
return mask;
}
public static int localVideoMask() {
return videoMaskFromMimes(CodecCatalog.localEncoderMimes());
}
public static int localDecoderVideoMask() {
return videoMaskFromMimes(CodecCatalog.localDecoderMimes());
}
public static int localAudioMask() {
return AUDIO_AAC | AUDIO_PCM;
}
}

View File

@@ -0,0 +1,26 @@
package com.foxx.androidcast.network;
/** Per-session values written into {@link CastProtoHeader}. */
public final class CastFramingContext {
public final int sessionId;
public final int placeholderId;
public final int videoCodecs;
public final int audioCodecs;
public CastFramingContext(int sessionId, int placeholderId, int videoCodecs, int audioCodecs) {
this.sessionId = sessionId;
this.placeholderId = placeholderId;
this.videoCodecs = videoCodecs;
this.audioCodecs = audioCodecs;
}
public CastProtoHeader newHeader(byte hsType) {
CastProtoHeader h = new CastProtoHeader();
h.hsType = hsType;
h.sessionId = sessionId;
h.placeholderId = placeholderId;
h.videoCodecs = videoCodecs;
h.audioCodecs = audioCodecs;
return h;
}
}

View File

@@ -0,0 +1,104 @@
package com.foxx.androidcast.network;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
/** Wraps {@link CastProtoHeader} + {@link CastProtocol} message body. */
public final class CastPacketFramer {
private CastPacketFramer() {}
public static byte[] frame(CastProtoHeader header, byte type, byte[] payload) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos);
CastProtocol.writeMessage(dos, type, payload);
byte[] inner = bos.toByteArray();
header.recomputeChecksum();
byte[] hdr = header.encode();
byte[] out = new byte[hdr.length + inner.length];
System.arraycopy(hdr, 0, out, 0, hdr.length);
System.arraycopy(inner, 0, out, hdr.length, inner.length);
return out;
}
public static CastProtocol.Message unframe(byte[] wire) throws IOException {
if (wire == null || wire.length < CastProtoHeader.HEADER_SIZE_V1 + 5) {
throw new IOException("Frame too short");
}
CastProtoHeader hdr = CastProtoHeader.decode(wire, 0, wire.length);
if (!hdr.verifyChecksum()) {
throw new IOException("Header checksum mismatch");
}
int innerOff = hdr.headerSize;
if (wire.length < innerOff + 5) {
throw new IOException("Missing message body");
}
DataInputStream dis = new DataInputStream(
new java.io.ByteArrayInputStream(wire, innerOff, wire.length - innerOff));
return CastProtocol.readMessage(dis);
}
/**
* Reads one framed message from a TCP stream (blocking).
* Returns null if stream ends before a full frame.
*/
public static CastProtocol.Message readFramedMessage(InputStream in) throws IOException {
byte[] magicBuf = readFully(in, 4);
if (magicBuf == null) {
return null;
}
int magic = (magicBuf[0] & 0xff) | ((magicBuf[1] & 0xff) << 8)
| ((magicBuf[2] & 0xff) << 16) | ((magicBuf[3] & 0xff) << 24);
if (magic != CastProtoHeader.MAGIC) {
throw new IOException("Bad magic on wire: 0x" + Integer.toHexString(magic));
}
byte[] restHdr = readFully(in, CastProtoHeader.HEADER_SIZE_V1 - 4);
if (restHdr == null) {
return null;
}
byte[] headerBytes = new byte[CastProtoHeader.HEADER_SIZE_V1];
System.arraycopy(magicBuf, 0, headerBytes, 0, 4);
System.arraycopy(restHdr, 0, headerBytes, 4, restHdr.length);
CastProtoHeader hdr = CastProtoHeader.decode(headerBytes, 0, headerBytes.length);
if (!hdr.verifyChecksum()) {
throw new IOException("Header checksum mismatch");
}
byte type = (byte) in.read();
if (type < 0) {
return null;
}
byte[] lenBuf = readFully(in, 4);
if (lenBuf == null) {
return null;
}
int len = ((lenBuf[0] & 0xff) << 24) | ((lenBuf[1] & 0xff) << 16)
| ((lenBuf[2] & 0xff) << 8) | (lenBuf[3] & 0xff);
if (len < 0 || len > 4 * 1024 * 1024) {
throw new IOException("Invalid payload length: " + len);
}
byte[] payload = len == 0 ? new byte[0] : readFully(in, len);
if (payload == null && len > 0) {
return null;
}
return new CastProtocol.Message(type, payload);
}
private static byte[] readFully(InputStream in, int n) throws IOException {
byte[] buf = new byte[n];
int off = 0;
while (off < n) {
int r = in.read(buf, off, n - off);
if (r < 0) {
if (off == 0) {
return null;
}
throw new EOFException("Short read");
}
off += r;
}
return buf;
}
}

View File

@@ -0,0 +1,131 @@
package com.foxx.androidcast.network;
import com.foxx.androidcast.VersionInfo;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.zip.CRC32;
/**
* Fixed binary header prefixed to every cast/discovery packet (little-endian).
* v1 size = 48 bytes.
*/
public final class CastProtoHeader {
public static final int MAGIC = 0xDEADBABE;
public static final int HEADER_SIZE_V1 = 48;
public static final byte HS_DEFAULT = 0;
public static final byte HS_SDP = 1;
public int magic = MAGIC;
public long protoVersion = VersionInfo.PROTO_VERSION;
public int headerSize = HEADER_SIZE_V1;
public int flags;
public int reserved;
public byte hsType = HS_DEFAULT;
public int videoCodecs;
public int audioCodecs;
public int placeholderId;
public int sessionId;
public int checksum;
public static CastProtoHeader forCast(int sessionId, int placeholderId, int videoCodecs, int audioCodecs) {
CastProtoHeader h = new CastProtoHeader();
h.hsType = HS_DEFAULT;
h.sessionId = sessionId;
h.placeholderId = placeholderId;
h.videoCodecs = videoCodecs;
h.audioCodecs = audioCodecs;
h.recomputeChecksum();
return h;
}
public static CastProtoHeader forDiscovery(int placeholderId, int videoCodecs, int audioCodecs) {
CastProtoHeader h = new CastProtoHeader();
h.hsType = HS_SDP;
h.sessionId = 0;
h.placeholderId = placeholderId;
h.videoCodecs = videoCodecs;
h.audioCodecs = audioCodecs;
h.recomputeChecksum();
return h;
}
public void recomputeChecksum() {
checksum = 0;
checksum = (int) crc32(encode());
}
public boolean verifyChecksum() {
int saved = checksum;
checksum = 0;
int expected = (int) crc32(encode());
checksum = saved;
return saved == expected;
}
public byte[] encode() {
ByteBuffer buf = ByteBuffer.allocate(HEADER_SIZE_V1).order(ByteOrder.LITTLE_ENDIAN);
buf.putInt(magic);
buf.putLong(protoVersion);
buf.putInt(headerSize);
buf.putInt(flags);
buf.putInt(reserved);
buf.put(hsType);
buf.put((byte) 0);
buf.put((byte) 0);
buf.put((byte) 0);
buf.putInt(videoCodecs);
buf.putInt(audioCodecs);
buf.putInt(placeholderId);
buf.putInt(sessionId);
buf.putInt(0);
byte[] out = buf.array();
checksum = (int) crc32(out);
ByteBuffer.wrap(out).order(ByteOrder.LITTLE_ENDIAN).putInt(44, checksum);
return out;
}
public static CastProtoHeader decode(byte[] bytes, int offset, int length) throws java.io.IOException {
if (length < HEADER_SIZE_V1) {
throw new java.io.IOException("Header too short: " + length);
}
ByteBuffer buf = ByteBuffer.wrap(bytes, offset, length).order(ByteOrder.LITTLE_ENDIAN);
CastProtoHeader h = new CastProtoHeader();
h.magic = buf.getInt();
if (h.magic != MAGIC) {
throw new java.io.IOException("Bad magic: 0x" + Integer.toHexString(h.magic));
}
h.protoVersion = buf.getLong();
h.headerSize = buf.getInt();
h.flags = buf.getInt();
h.reserved = buf.getInt();
h.hsType = buf.get();
buf.get();
buf.get();
buf.get();
h.videoCodecs = buf.getInt();
h.audioCodecs = buf.getInt();
h.placeholderId = buf.getInt();
h.sessionId = buf.getInt();
h.checksum = buf.getInt();
if (h.headerSize < HEADER_SIZE_V1) {
h.headerSize = HEADER_SIZE_V1;
}
return h;
}
private static long crc32(byte[] data) {
CRC32 crc = new CRC32();
int checksumOffset = 44;
crc.update(data, 0, checksumOffset);
crc.update((byte) 0);
crc.update((byte) 0);
crc.update((byte) 0);
crc.update((byte) 0);
if (data.length > checksumOffset + 4) {
crc.update(data, checksumOffset + 4, data.length - checksumOffset - 4);
}
return crc.getValue();
}
}

View File

@@ -31,6 +31,8 @@ public final class CastProtocol {
public static final byte MSG_AUDIO_CONSENT = 13; public static final byte MSG_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;
} }

View File

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

View File

@@ -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,15 +78,27 @@ 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");
} }
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); CastProtocol.writeMessage(out, type, payload);
} }
} }
}
/** /**
* Waits up to {@code timeoutMs} for data, then reads one full message without a socket read timeout. * Waits up to {@code timeoutMs} for data, then reads one full message without a socket read timeout.
@@ -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

View File

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

View File

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

View File

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

View File

@@ -1,58 +1,201 @@
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 {
final IOException[] error = new IOException[1];
final Object lock = new Object();
worker.post(() -> {
try {
releaseOnWorker();
AudioDecoder.this.sampleRate = sampleRate;
AudioDecoder.this.channels = channels;
useAdtsMode = csd0 == null || csd0.length < 2;
MediaFormat format = MediaFormat.createAudioFormat(MIME, sampleRate, channels); MediaFormat format = MediaFormat.createAudioFormat(MIME, sampleRate, channels);
if (csd0 != null && csd0.length > 0) { if (useAdtsMode) {
format.setInteger(MediaFormat.KEY_IS_ADTS, 1);
} else {
format.setByteBuffer("csd-0", ByteBuffer.wrap(csd0)); format.setByteBuffer("csd-0", ByteBuffer.wrap(csd0));
} }
codec = MediaCodec.createDecoderByType(MIME); codec = MediaCodec.createDecoderByType(MIME);
codec.configure(format, null, null, 0); codec.configure(format, null, null, 0);
codec.start(); 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];
}
}
int channelMask = channels >= 2 ? AudioFormat.CHANNEL_OUT_STEREO : AudioFormat.CHANNEL_OUT_MONO; 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;
} }
stats.framesQueued++;
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
inputQueue.poll();
inputQueue.offer(new PendingFrame(ptsUs, data.clone()));
}
}
private void drainLoop() {
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
while (draining) {
try { try {
int inIndex = codec.dequeueInputBuffer(5_000); PendingFrame frame = inputQueue.poll(5, TimeUnit.MILLISECONDS);
if (frame != null && codec != null) {
feedFrameSync(frame, info);
}
drainOutputSync(info);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (Exception e) {
stats.decodeErrors++;
if (draining) {
Log.w(TAG, "drainLoop: " + e.getMessage());
}
}
}
}
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) { if (inIndex < 0) {
return; return;
} }
@@ -62,29 +205,106 @@ public class AudioDecoder {
} }
buffer.clear(); buffer.clear();
buffer.put(data); buffer.put(data);
codec.queueInputBuffer(inIndex, 0, data.length, ptsUs, 0); codec.queueInputBuffer(inIndex, 0, data.length, frame.ptsUs, 0);
stats.framesFed++;
drainOutputSync(info);
}
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); private void drainOutputSync(MediaCodec.BufferInfo info) {
int outIndex = codec.dequeueOutputBuffer(info, 5_000); if (codec == null) {
while (outIndex >= 0) { 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); ByteBuffer outBuf = codec.getOutputBuffer(outIndex);
if (outBuf != null && info.size > 0 && track != null) { if (outBuf != null && info.size > 0 && track != null
&& (info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0) {
byte[] pcm = new byte[info.size]; byte[] pcm = new byte[info.size];
outBuf.position(info.offset); outBuf.position(info.offset);
outBuf.limit(info.offset + info.size); outBuf.limit(info.offset + info.size);
outBuf.get(pcm); outBuf.get(pcm);
track.write(pcm, 0, pcm.length); 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); codec.releaseOutputBuffer(outIndex, false);
outIndex = codec.dequeueOutputBuffer(info, 0);
} }
} catch (Exception e) { }
Log.e(TAG, "Audio decode error", e);
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();
}
} }

View File

@@ -0,0 +1,21 @@
package com.foxx.androidcast.receiver;
/** Zoom/pan state for receiver diagnostics (updated from playback UI). */
public final class PlaybackViewState {
private static volatile int zoomPercent;
private PlaybackViewState() {}
public static void setZoomPercent(int percent) {
zoomPercent = percent;
}
/** Deviation from 100% scale, e.g. +15 or 10. */
public static int getZoomPercent() {
return zoomPercent;
}
public static void reset() {
zoomPercent = 0;
}
}

View File

@@ -93,7 +93,7 @@ public class ReceiverActivity extends AppCompatActivity {
super.onCreate(savedInstanceState); 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();
} }

View File

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

View File

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

View File

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

View File

@@ -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 — WiFi congestion\n");
}
if (renderFps > 0 && inFps > renderFps + 3f) {
sb.append("Hint: decode slower than network\n");
}
sb.append("Audio pkts: ").append(audioPackets);
if (audioPackets == 0 && videoPackets > 20) {
sb.append(" (play media on sender for capture audio)");
}
sb.append('\n');
sb.append("Video bytes: ").append(formatBytes(videoBytes));
sb.append(" Audio bytes: ").append(formatBytes(audioBytes)).append('\n');
sb.append("State: ").append(idle ? "idle (awaiting)" : "streaming");
if (idleMs >= 0) {
sb.append(" last frame: ").append(idleMs).append(" ms ago");
}
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));
}
} }

View File

@@ -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; private AudioRecord buildMicRecord() {
record.startRecording(); int channelMask = AudioFormat.CHANNEL_IN_STEREO;
Log.i(TAG, "Playback audio capture started"); int encoding = AudioFormat.ENCODING_PCM_16BIT;
thread = new Thread(() -> readLoop(callback), "AudioCapture"); int minBuf = AudioRecord.getMinBufferSize(CastConfig.AUDIO_SAMPLE_RATE, channelMask, encoding);
thread.start(); int bufferSize = Math.max(minBuf, 8192) * 2;
return new AudioRecord(
MediaRecorder.AudioSource.VOICE_RECOGNITION,
CastConfig.AUDIO_SAMPLE_RATE,
channelMask,
encoding,
bufferSize);
} }
private void readLoop(Callback callback) { private boolean canUseMic() {
byte[] buf = new byte[4096]; 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;
if (pRead < 0 && mRead < 0) {
Log.w(TAG, "Audio read error");
continue;
}
byte[] out;
int outLen;
if (pRead > 0) {
out = playBuf;
outLen = pRead;
String mode = hasAudibleSamples(playBuf, pRead) ? "playback" : "playback-quiet";
if (!mode.equals(captureMode)) {
captureMode = mode;
notifyMode(callback, captureMode);
}
} else if (mRead > 0 && micRecord != null) {
out = micBuf;
outLen = mRead;
if (!"mic".equals(captureMode)) {
captureMode = "mic";
notifyMode(callback, captureMode);
}
} else {
continue;
}
long ptsUs = (System.nanoTime() - startNs) / 1000; long ptsUs = (System.nanoTime() - startNs) / 1000;
callback.onPcm(buf, read, ptsUs); if (callback != null) {
} else if (read < 0) { PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(out, outLen);
Log.w(TAG, "Audio read error: " + read); 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;
} }
} }
} }

View File

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

View File

@@ -0,0 +1,130 @@
package com.foxx.androidcast.sender;
import android.util.Log;
import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.CastSession;
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/** Fan-out send queue to multiple {@link CastSession}s (1:N cast). */
public final class CastFanoutPump {
private static final String TAG = "CastFanoutPump";
private static final int MAX_QUEUE = 48;
private final LinkedBlockingQueue<CastSendPump.Outgoing> queue = new LinkedBlockingQueue<>(MAX_QUEUE);
private final AtomicBoolean running = new AtomicBoolean(false);
private final List<CastSession> sessions = new ArrayList<>();
private Thread thread;
private Runnable onSendFailed;
private PassThroughNetworkControlPlane statsPlane;
private volatile int droppedPFrames;
public void setSessions(List<CastSession> list) {
sessions.clear();
if (list != null) {
sessions.addAll(list);
}
}
public int getDroppedPFrames() {
return droppedPFrames;
}
public int getQueueDepth() {
return queue.size();
}
public void start(List<CastSession> sessionList, Runnable onSendFailed,
PassThroughNetworkControlPlane statsPlane) {
setSessions(sessionList);
this.onSendFailed = onSendFailed;
this.statsPlane = statsPlane;
if (!running.compareAndSet(false, true)) {
return;
}
thread = new Thread(this::run, "CastFanoutPump");
thread.start();
}
public void stop() {
running.set(false);
if (thread != null) {
thread.interrupt();
try {
thread.join(1500);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
thread = null;
}
queue.clear();
sessions.clear();
}
public void enqueueVideo(long ptsUs, boolean keyFrame, byte[] annexBReadyPayload) {
if (!running.get()) {
return;
}
CastSendPump.Outgoing item = CastSendPump.Outgoing.video(ptsUs, keyFrame, annexBReadyPayload);
if (!queue.offer(item)) {
if (!keyFrame) {
CastSendPump.Outgoing dropped = queue.poll();
if (dropped != null && !dropped.keyFrame) {
droppedPFrames++;
}
}
queue.offer(item);
}
}
public void enqueue(byte type, byte[] payload) {
if (!running.get()) {
return;
}
CastSendPump.Outgoing item = CastSendPump.Outgoing.raw(type, payload);
while (running.get() && !queue.offer(item)) {
queue.poll();
}
}
private void run() {
while (running.get()) {
try {
CastSendPump.Outgoing item = queue.poll(200, TimeUnit.MILLISECONDS);
if (item == null || sessions.isEmpty()) {
continue;
}
byte[] payload = item.buildPayload();
for (CastSession session : sessions) {
if (session == null) {
continue;
}
try {
session.send(item.type, payload);
if (statsPlane != null) {
statsPlane.recordSendBytes(payload.length + 5);
}
} catch (IOException e) {
Log.w(TAG, "Send to peer failed: " + e.getMessage());
}
}
} catch (InterruptedException e) {
if (!running.get()) {
break;
}
} catch (IOException e) {
Log.w(TAG, "Payload build failed: " + e.getMessage());
if (onSendFailed != null) {
onSendFailed.run();
}
}
}
}
}

View File

@@ -0,0 +1,29 @@
package com.foxx.androidcast.sender;
import java.io.Serializable;
/** One cast destination (host, port, capabilities from discovery). */
public class CastReceiverTarget implements Serializable {
public final String name;
public final String host;
public final int port;
public final String transport;
public final int videoCodecs;
public final int audioCodecs;
public final int placeholderId;
public CastReceiverTarget(String name, String host, int port, String transport,
int videoCodecs, int audioCodecs, int placeholderId) {
this.name = name;
this.host = host;
this.port = port;
this.transport = transport;
this.videoCodecs = videoCodecs;
this.audioCodecs = audioCodecs;
this.placeholderId = placeholderId;
}
public String key() {
return host + ":" + port;
}
}

View File

@@ -117,28 +117,28 @@ public final class CastSendPump {
} }
} }
private static final class Outgoing { static final class Outgoing {
final byte type; final byte 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;
} }

View File

@@ -12,21 +12,27 @@ 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 boolean multiSelect;
private final List<DiscoveryManager.DiscoveredDevice> devices = new ArrayList<>(); private final List<DiscoveryManager.DiscoveredDevice> devices = new ArrayList<>();
private int selectedPosition = -1; 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() { public void clearDevices() {
devices.clear(); devices.clear();
selectedPosition = -1; selectedPosition = -1;
selectedPositions.clear();
notifyDataSetChanged(); notifyDataSetChanged();
} }
@@ -38,11 +44,22 @@ public class DeviceListAdapter extends BaseAdapter {
if (selectedPosition >= devices.size()) { if (selectedPosition >= devices.size()) {
selectedPosition = -1; selectedPosition = -1;
} }
selectedPositions.removeIf(i -> i >= devices.size());
notifyDataSetChanged(); notifyDataSetChanged();
} }
public void setSelectedPosition(int position) { public void setSelectedPosition(int position) {
if (multiSelect) {
if (position >= 0 && position < devices.size()) {
if (selectedPositions.contains(position)) {
selectedPositions.remove(position);
} else {
selectedPositions.add(position);
}
}
} else {
selectedPosition = position; selectedPosition = position;
}
notifyDataSetChanged(); notifyDataSetChanged();
} }
@@ -50,11 +67,23 @@ public class DeviceListAdapter extends BaseAdapter {
return selectedPosition; return selectedPosition;
} }
public DiscoveryManager.DiscoveredDevice getSelectedDevice() { public List<DiscoveryManager.DiscoveredDevice> getSelectedDevices() {
if (selectedPosition < 0 || selectedPosition >= devices.size()) { List<DiscoveryManager.DiscoveredDevice> out = new ArrayList<>();
return null; if (multiSelect) {
for (int i : selectedPositions) {
if (i >= 0 && i < devices.size()) {
out.add(devices.get(i));
} }
return devices.get(selectedPosition); }
} else if (selectedPosition >= 0 && selectedPosition < devices.size()) {
out.add(devices.get(selectedPosition));
}
return out;
}
public DiscoveryManager.DiscoveredDevice getSelectedDevice() {
List<DiscoveryManager.DiscoveredDevice> list = getSelectedDevices();
return list.isEmpty() ? null : list.get(0);
} }
@Override @Override
@@ -81,7 +110,7 @@ public class DeviceListAdapter extends BaseAdapter {
TextView label = row.findViewById(R.id.device_label); TextView label = row.findViewById(R.id.device_label);
DiscoveryManager.DiscoveredDevice d = devices.get(position); DiscoveryManager.DiscoveredDevice d = devices.get(position);
label.setText(d.name + "" + d.host + " [" + d.transport.toUpperCase() + "]"); label.setText(d.name + "" + d.host + " [" + d.transport.toUpperCase() + "]");
boolean selected = position == selectedPosition; boolean selected = multiSelect ? selectedPositions.contains(position) : position == selectedPosition;
row.setActivated(selected); row.setActivated(selected);
row.setSelected(selected); row.setSelected(selected);
label.setTypeface(null, selected ? Typeface.BOLD : Typeface.NORMAL); label.setTypeface(null, selected ? Typeface.BOLD : Typeface.NORMAL);

View File

@@ -0,0 +1,201 @@
package com.foxx.androidcast.sender;
import android.content.Context;
import android.util.Log;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.DeviceInfo;
import com.foxx.androidcast.network.CastCodecFlags;
import com.foxx.androidcast.network.CastFramingContext;
import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.CastSession;
import com.foxx.androidcast.network.CastTransportFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* Manages multiple receiver sessions (connect, handshake, fan-out send).
* Requests keyframes when a peer joins or changes transport.
*/
public final class MultiCastCoordinator {
private static final String TAG = "MultiCastCoordinator";
private static final int CONNECT_RETRIES = 20;
public interface KeyframeRequester {
void requestKeyframe();
}
public static final class ActivePeer {
public final CastReceiverTarget target;
public final CastSession session;
public CastSettings settings;
public String negotiatedMime;
ActivePeer(CastReceiverTarget target, CastSession session, CastSettings settings, String mime) {
this.target = target;
this.session = session;
this.settings = settings;
this.negotiatedMime = mime;
}
}
private final Context appContext;
private final int sessionId;
private final CastFramingContext framingContext;
private final CopyOnWriteArrayList<ActivePeer> peers = new CopyOnWriteArrayList<>();
private KeyframeRequester keyframeRequester;
public MultiCastCoordinator(Context context) {
appContext = context.getApplicationContext();
sessionId = new Random().nextInt();
int placeholderId = DeviceInfo.getPlaceholderId(appContext);
framingContext = new CastFramingContext(
sessionId,
placeholderId,
CastCodecFlags.localVideoMask(),
CastCodecFlags.localAudioMask());
}
public int getSessionId() {
return sessionId;
}
public CastFramingContext getFramingContext() {
return framingContext;
}
public void setKeyframeRequester(KeyframeRequester requester) {
this.keyframeRequester = requester;
}
public List<ActivePeer> getPeers() {
return new ArrayList<>(peers);
}
public List<CastSession> getSessions() {
List<CastSession> out = new ArrayList<>();
for (ActivePeer p : peers) {
out.add(p.session);
}
return out;
}
public void connectAndHandshake(List<CastReceiverTarget> targets, CastSettings baseSettings,
String deviceName, String pin) throws IOException {
peers.clear();
IOException last = null;
for (CastReceiverTarget t : targets) {
CastSettings peerSettings = cloneSettings(baseSettings);
peerSettings.setTransport(t.transport);
CastSession session = null;
try {
session = createSession(peerSettings);
connectWithRetry(session, t.host, t.port);
CastSession.HandshakeResult hs = session.clientHandshake(deviceName, pin, peerSettings);
peers.add(new ActivePeer(t, session, hs.settings, hs.negotiatedVideoMime));
Log.i(TAG, "Peer ready " + t.name + " " + hs.negotiatedVideoMime);
} catch (IOException e) {
last = e;
Log.w(TAG, "Peer failed " + t.name + ": " + e.getMessage());
if (session != null) {
session.close();
}
}
}
if (peers.isEmpty() && last != null) {
throw last;
}
if (keyframeRequester != null) {
keyframeRequester.requestKeyframe();
}
}
public void addPeer(CastReceiverTarget target, CastSettings baseSettings, String deviceName, String pin)
throws IOException {
CastSettings peerSettings = cloneSettings(baseSettings);
peerSettings.setTransport(target.transport);
CastSession session = createSession(peerSettings);
connectWithRetry(session, target.host, target.port);
CastSession.HandshakeResult hs = session.clientHandshake(deviceName, pin, peerSettings);
peers.add(new ActivePeer(target, session, hs.settings, hs.negotiatedVideoMime));
if (keyframeRequester != null) {
keyframeRequester.requestKeyframe();
}
}
public void removePeer(String host, int port) {
String key = host + ":" + port;
for (ActivePeer p : peers) {
if (p.target.key().equals(key)) {
peers.remove(p);
p.session.close();
break;
}
}
}
public CastProtocol.Message pollReceive(int timeoutMs) throws IOException {
CastProtocol.Message any = null;
for (ActivePeer p : peers) {
CastProtocol.Message msg = p.session.receive(timeoutMs);
if (msg != null) {
any = msg;
}
}
return any;
}
public void closeAll() {
for (ActivePeer p : peers) {
try {
p.session.send(CastProtocol.MSG_GOODBYE, new byte[0]);
} catch (IOException ignored) {
}
p.session.close();
}
peers.clear();
}
private CastSession createSession(CastSettings settings) throws IOException {
CastSession session = new CastSession(CastTransportFactory.create(settings));
session.getTransport().setFramingContext(framingContext);
return session;
}
private void connectWithRetry(CastSession session, String host, int port) throws IOException {
IOException last = null;
for (int i = 0; i < CONNECT_RETRIES; i++) {
try {
session.connect(host, port);
return;
} catch (IOException e) {
last = e;
try {
Thread.sleep(1000);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted", ie);
}
}
}
throw last != null ? last : new IOException("Connect failed");
}
private static CastSettings cloneSettings(CastSettings src) {
CastSettings s = new CastSettings();
s.setTransport(src.getTransport());
s.setQuality(src.getQuality());
s.setResolution(src.getResolution());
s.setAudioEnabled(src.isAudioEnabled());
s.setVideoCodec(src.getVideoCodec());
s.setAdjustmentPreset(src.getAdjustmentPreset());
s.setBitrateMode(src.getBitrateMode());
s.setNetworkAdoption(src.getNetworkAdoption());
s.setCaptureMode(src.getCaptureMode());
return s;
}
}

View File

@@ -20,6 +20,7 @@ import android.util.Log;
import android.view.Surface; import android.view.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();
plane.setStatsEnricher(this::enrichSenderStats);
if (peers.size() > 1) {
fanoutPump = new CastFanoutPump();
fanoutPump.start(multiCoordinator.getSessions(), () -> stopping.set(true), plane);
} else {
sendPump = new CastSendPump(); sendPump = new CastSendPump();
sendPump.start(session, () -> stopping.set(true), plane); 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();
audioCaptureMode = "playback";
audioCapture = new AudioCapture(this);
audioCapture.start(projection, new AudioCapture.Callback() {
@Override
public void onPcm(byte[] pcm, int length, long ptsUs,
com.foxx.androidcast.media.PcmLevelAnalyzer.Levels levels) {
senderAudioMetrics.onPcm(levels);
if (audioEncoder != null) { if (audioEncoder != null) {
audioEncoder.queuePcm(pcm, length, ptsUs); 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);
} }

View File

@@ -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 (!CastConfig.MULTI_RECEIVER_ENABLED) {
for (DiscoveryManager.DiscoveredDevice selected : selectedList) {
if (!castSettings.getTransport().equals(selected.transport)) { if (!castSettings.getTransport().equals(selected.transport)) {
Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show(); 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, statusText.setText(getString(R.string.device_selected, selected.name, selected.host,
selected.transport.toUpperCase())); selected.transport.toUpperCase()));
} else {
statusText.setText(getString(R.string.devices_selected, selectedList.size()));
}
}); });
discovery = new DiscoveryManager(this); discovery = new DiscoveryManager(this);
discovery.setListener(deviceList -> { discovery.setListener(new DiscoveryManager.Listener() {
discoveryProgress.setVisibility(View.GONE); @Override
public void onDevicesUpdated(java.util.List<DiscoveryManager.DiscoveredDevice> deviceList) {
deviceAdapter.setDevices(deviceList); deviceAdapter.setDevices(deviceList);
statusText.setText(deviceList.isEmpty() statusText.setText(deviceList.isEmpty()
? getString(R.string.searching) ? getString(R.string.searching)
: getString(R.string.found_receivers, deviceList.size())); : 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, getString(R.string.too_many_receivers, CastConfig.MAX_RECEIVERS),
Toast.LENGTH_LONG).show();
return;
}
pendingTargets = new java.util.ArrayList<>();
for (DiscoveryManager.DiscoveredDevice d : selectedList) {
if (!CastConfig.MULTI_RECEIVER_ENABLED
&& !castSettings.getTransport().equals(d.transport)) {
Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show(); Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
return; return;
} }
pendingHost = selected.host; pendingTargets.add(new CastReceiverTarget(d.name, d.host, d.port, d.transport,
pendingPort = selected.port; d.videoCodecs, d.audioCodecs, d.placeholderId));
pendingDeviceName = selected.name; }
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);

View File

@@ -0,0 +1,61 @@
package com.foxx.androidcast.sender;
import com.foxx.androidcast.media.PcmLevelAnalyzer;
/** Pre-encode PCM level stats on the sender. */
public final class SenderAudioMetrics {
private long silentBlocks;
private long audibleBlocks;
private float vuSmoothed;
private float lastPeakDb = -96f;
private float lastRmsDb = -96f;
public synchronized void onPcm(PcmLevelAnalyzer.Levels levels) {
if (levels == null) {
return;
}
if (levels.silent) {
silentBlocks++;
} else {
audibleBlocks++;
}
float instant = Math.max(levels.peak, levels.rms);
vuSmoothed = instant > vuSmoothed
? instant * 0.35f + vuSmoothed * 0.65f
: instant * 0.12f + vuSmoothed * 0.88f;
if (levels.peak > 1e-9f) {
lastPeakDb = levels.peakDb();
}
if (levels.rms > 1e-9f) {
lastRmsDb = (float) (20.0 * Math.log10(levels.rms));
}
}
public synchronized void reset() {
silentBlocks = 0;
audibleBlocks = 0;
vuSmoothed = 0f;
lastPeakDb = -96f;
lastRmsDb = -96f;
}
public synchronized long getSilentBlocks() {
return silentBlocks;
}
public synchronized long getAudibleBlocks() {
return audibleBlocks;
}
public synchronized int getVuPercent() {
return PcmLevelAnalyzer.vuPercent(vuSmoothed);
}
public synchronized float getLastPeakDb() {
return lastPeakDb;
}
public synchronized float getLastRmsDb() {
return lastRmsDb;
}
}

View File

@@ -27,6 +27,10 @@ public final class SenderStreamMetrics {
windowFrames++; 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();
} }
} }

View File

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

View File

@@ -0,0 +1,134 @@
package com.foxx.androidcast.ui;
import android.content.Context;
import android.util.AttributeSet;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import android.widget.FrameLayout;
/**
* Pinch-zoom (5% steps) and two-finger pan over video content.
* Reports zoom deviation from 100% via {@link OnZoomChangedListener}.
*/
public class ZoomPanContainer extends FrameLayout {
public interface OnZoomChangedListener {
void onZoomPercentChanged(int zoomPercent);
}
private static final float STEP = 0.05f;
private static final float MIN_SCALE = 0.50f;
private static final float MAX_SCALE = 3.00f;
private float scale = 1f;
private float panX;
private float panY;
private OnZoomChangedListener zoomListener;
private final ScaleGestureDetector scaleDetector;
private final GestureDetector panDetector;
private float lastSpan = -1f;
public ZoomPanContainer(Context context) {
this(context, null);
}
public ZoomPanContainer(Context context, AttributeSet attrs) {
super(context, attrs);
scaleDetector = new ScaleGestureDetector(context, new ScaleListener());
panDetector = new GestureDetector(context, new PanListener());
}
public void setOnZoomChangedListener(OnZoomChangedListener listener) {
this.zoomListener = listener;
}
public void resetTransform() {
scale = 1f;
panX = 0f;
panY = 0f;
applyTransform();
notifyZoom();
}
public int getZoomPercent() {
return Math.round((scale - 1f) * 100f);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
boolean scaled = scaleDetector.onTouchEvent(event);
boolean panned = panDetector.onTouchEvent(event);
if (event.getActionMasked() == MotionEvent.ACTION_UP
|| event.getActionMasked() == MotionEvent.ACTION_CANCEL) {
lastSpan = -1f;
}
return scaled || panned || super.onTouchEvent(event);
}
private final class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
@Override
public boolean onScale(ScaleGestureDetector detector) {
float span = detector.getCurrentSpan();
if (lastSpan < 0) {
lastSpan = span;
return true;
}
float ratio = span / lastSpan;
lastSpan = span;
if (ratio > 1.02f) {
applyStep(1f + STEP);
} else if (ratio < 0.98f) {
applyStep(1f - STEP);
}
return true;
}
}
private void applyStep(float factor) {
float target = scale * factor;
target = Math.max(MIN_SCALE, Math.min(MAX_SCALE, target));
int quantized = Math.round(target * 20f);
scale = quantized / 20f;
applyTransform();
notifyZoom();
}
private final class PanListener extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
if (e1 == null || e2 == null || e2.getPointerCount() < 2) {
return false;
}
panX -= distanceX;
panY -= distanceY;
applyTransform();
return true;
}
}
private void applyTransform() {
if (getChildCount() == 0) {
return;
}
android.view.View child = getChildAt(0);
child.setPivotX(child.getWidth() / 2f);
child.setPivotY(child.getHeight() / 2f);
child.setScaleX(scale);
child.setScaleY(scale);
child.setTranslationX(panX);
child.setTranslationY(panY);
}
private void notifyZoom() {
if (zoomListener != null) {
zoomListener.onZoomPercentChanged(getZoomPercent());
}
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
applyTransform();
}
}

View File

@@ -4,6 +4,12 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="#000000"> android:background="#000000">
<com.foxx.androidcast.ui.ZoomPanContainer
android:id="@+id/zoom_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center">
<com.foxx.androidcast.ui.AspectRatioFrameLayout <com.foxx.androidcast.ui.AspectRatioFrameLayout
android:id="@+id/aspect_container" android:id="@+id/aspect_container"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -17,6 +23,7 @@
android:layout_gravity="center" android:layout_gravity="center"
android:keepScreenOn="true" /> android:keepScreenOn="true" />
</com.foxx.androidcast.ui.AspectRatioFrameLayout> </com.foxx.androidcast.ui.AspectRatioFrameLayout>
</com.foxx.androidcast.ui.ZoomPanContainer>
<include <include
layout="@layout/overlay_stream_awaiting" layout="@layout/overlay_stream_awaiting"

View File

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

View File

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

View File

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