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