From 367a21e37a099ea76fff7374e55841c15b438a31 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Fri, 15 May 2026 20:37:44 +0200 Subject: [PATCH] snap --- .../java/com/foxx/androidcast/CastConfig.java | 6 + .../com/foxx/androidcast/CastResolution.java | 55 +++-- .../com/foxx/androidcast/CastSettings.java | 79 ++++++- .../foxx/androidcast/CastTuningEngine.java | 173 +++++++++++++++ .../java/com/foxx/androidcast/SettingsUi.java | 173 ++++++++++++--- .../discovery/DiscoveryManager.java | 61 ++++-- .../androidcast/media/CodecNegotiator.java | 75 +++++++ .../androidcast/network/CastProtocol.java | 90 +++++++- .../foxx/androidcast/network/CastSession.java | 48 ++++- .../PassThroughNetworkControlPlane.java | 4 + .../receiver/ReceiverCastService.java | 34 ++- .../androidcast/receiver/ReceiverSession.java | 4 +- .../androidcast/receiver/StreamMetrics.java | 24 ++- .../androidcast/receiver/VideoDecoder.java | 25 ++- .../foxx/androidcast/sender/AudioCapture.java | 5 + .../foxx/androidcast/sender/CastSendPump.java | 20 +- .../foxx/androidcast/sender/CodecCatalog.java | 87 ++++++++ .../androidcast/sender/EncoderKeepalive.java | 46 ++++ .../androidcast/sender/ScreenCastService.java | 202 +++++++++++++++--- .../androidcast/sender/SenderActivity.java | 121 +++++++++-- .../sender/SenderStreamMetrics.java | 65 ++++++ .../foxx/androidcast/sender/VideoEncoder.java | 40 ++-- app/src/main/res/layout/activity_sender.xml | 67 +++++- .../res/layout/activity_settings_panel.xml | 44 ++++ app/src/main/res/values/strings.xml | 31 ++- 25 files changed, 1400 insertions(+), 179 deletions(-) create mode 100644 app/src/main/java/com/foxx/androidcast/CastTuningEngine.java create mode 100644 app/src/main/java/com/foxx/androidcast/media/CodecNegotiator.java create mode 100644 app/src/main/java/com/foxx/androidcast/sender/CodecCatalog.java create mode 100644 app/src/main/java/com/foxx/androidcast/sender/EncoderKeepalive.java create mode 100644 app/src/main/java/com/foxx/androidcast/sender/SenderStreamMetrics.java diff --git a/app/src/main/java/com/foxx/androidcast/CastConfig.java b/app/src/main/java/com/foxx/androidcast/CastConfig.java index 3e41ef1..1e95e82 100644 --- a/app/src/main/java/com/foxx/androidcast/CastConfig.java +++ b/app/src/main/java/com/foxx/androidcast/CastConfig.java @@ -13,6 +13,12 @@ public final class CastConfig { /** Seconds between IDR frames (1 = faster recovery, slightly more bandwidth). */ public static final int VIDEO_I_FRAME_INTERVAL = 1; + /** Request sync frames on this interval when the screen is static (Mediatek stall workaround). */ + public static final long KEYFRAME_KEEPALIVE_MS = 2_000; + + /** P-frames smaller than this are treated as empty and not sent (unless keepalive IDR). */ + public static final int MIN_SIGNIFICANT_FRAME_BYTES = 400; + public static final int AUDIO_SAMPLE_RATE = 44100; public static final int AUDIO_CHANNEL_COUNT = 2; public static final int AUDIO_BITRATE = 128_000; diff --git a/app/src/main/java/com/foxx/androidcast/CastResolution.java b/app/src/main/java/com/foxx/androidcast/CastResolution.java index 13ef2a8..9a2655e 100644 --- a/app/src/main/java/com/foxx/androidcast/CastResolution.java +++ b/app/src/main/java/com/foxx/androidcast/CastResolution.java @@ -2,6 +2,8 @@ package com.foxx.androidcast; import android.util.DisplayMetrics; +import com.foxx.androidcast.network.control.NetworkStatsSnapshot; + /** Computes encoder VirtualDisplay size from display metrics and user settings. */ public final class CastResolution { private CastResolution() {} @@ -19,26 +21,53 @@ public final class CastResolution { } public static Size compute(DisplayMetrics display, CastSettings settings) { + return compute(display, settings, null); + } + + public static Size compute(DisplayMetrics display, CastSettings settings, NetworkStatsSnapshot peer) { int displayW = display.widthPixels & ~1; int displayH = display.heightPixels & ~1; float scale = settings.getResolutionScale(); - int encW = Math.max(2, ((int) (displayW * scale)) & ~1); - int encH = Math.max(2, ((int) (displayH * scale)) & ~1); + int encW = even(Math.max(2, (int) (displayW * scale))); + int encH = even(Math.max(2, (int) (displayH * scale))); - int maxH = settings.getMaxHeight(); - if (maxH > 0 && encH > maxH) { - float ratio = (float) maxH / encH; - encH = maxH & ~1; - encW = Math.max(2, ((int) (encW * ratio)) & ~1); - } - int maxW = settings.getMaxWidth(); - if (maxW > 0 && encW > maxW) { - float ratio = (float) maxW / encW; - encW = maxW & ~1; - encH = Math.max(2, ((int) (encH * ratio)) & ~1); + CastTuningEngine.CaptureLimits limits = CastTuningEngine.resolveCaptureLimits(settings, peer); + int maxH = limits.maxHeight > 0 ? limits.maxHeight : settings.getMaxHeight(); + int maxW = limits.maxWidth > 0 ? limits.maxWidth : settings.getMaxWidth(); + + if (maxH > 0 && maxW == 0) { + int[] capped = capLongEdge(encW, encH, maxH); + encW = capped[0]; + encH = capped[1]; + } else { + if (maxH > 0 && encH > maxH) { + float ratio = (float) maxH / encH; + encH = even(maxH); + encW = even(Math.max(2, (int) (encW * ratio))); + } + if (maxW > 0 && encW > maxW) { + float ratio = (float) maxW / encW; + encW = even(maxW); + encH = even(Math.max(2, (int) (encH * ratio))); + } } int density = Math.max(120, (int) (display.densityDpi * scale)); return new Size(encW, encH, density); } + + /** Caps the longer side (orientation-independent 720p height-cap mode). */ + private static int[] capLongEdge(int width, int height, int maxLong) { + int longEdge = Math.max(width, height); + if (longEdge <= maxLong) { + return new int[] {width, height}; + } + float ratio = (float) maxLong / longEdge; + return new int[] {even(Math.max(2, (int) (width * ratio))), + even(Math.max(2, (int) (height * ratio)))}; + } + + private static int even(int v) { + return Math.max(2, v & ~1); + } } diff --git a/app/src/main/java/com/foxx/androidcast/CastSettings.java b/app/src/main/java/com/foxx/androidcast/CastSettings.java index c8a3538..6e39d7e 100644 --- a/app/src/main/java/com/foxx/androidcast/CastSettings.java +++ b/app/src/main/java/com/foxx/androidcast/CastSettings.java @@ -5,6 +5,7 @@ import java.io.Serializable; /** User-selected cast parameters (passed sender → service → receiver via protocol). */ public class CastSettings implements Serializable { public static final String EXTRA = "cast_settings"; + public static final int SETTINGS_VERSION = 2; public enum Quality { LOW(1_500_000, 24), @@ -20,12 +21,34 @@ public class CastSettings implements Serializable { } } - /** Capture scale relative to physical display (1.0 = native). */ + public enum VideoCodec { + AUTO, H264, H265, PASSTHROUGH + } + + public enum AdjustmentPreset { + AUTO, NONE, QUALITY, SPEED, PERFORMANCE + } + + public enum BitrateMode { + AUTO, NONE, QUALITY, SPEED, BALANCED, ROBUST_LESS, ESTIMATED + } + + public enum NetworkAdoption { + AUTO, QUALITY, SPEED, ESTIMATED + } + + /** + * Capture size. {@link #HD_720P} caps height only (width follows aspect — e.g. 1532×720). + * {@link #FIXED_720P} fits inside 1280×720 box. + */ public enum Resolution { FULL(1.0f, 0, 0), THREE_QUARTERS(0.75f, 0, 0), HALF(0.5f, 0, 0), - HD_720P(1.0f, 720, 1280); + HD_720P(1.0f, 720, 0), + FIXED_1080P(0f, 1080, 1920), + FIXED_720P(0f, 720, 1280), + ADAPTIVE(1.0f, 0, 0); public final float scale; public final int maxHeight; @@ -42,6 +65,12 @@ public class CastSettings implements Serializable { private Quality quality = Quality.MEDIUM; private Resolution resolution = Resolution.HD_720P; private boolean audioEnabled; + private VideoCodec videoCodec = VideoCodec.AUTO; + private AdjustmentPreset adjustmentPreset = AdjustmentPreset.AUTO; + private BitrateMode bitrateMode = BitrateMode.AUTO; + private NetworkAdoption networkAdoption = NetworkAdoption.AUTO; + /** Filled after codec handshake (e.g. video/avc). */ + private transient String negotiatedVideoMime; public CastSettings() { PlatformCastSupport.applyDefaultAudioSetting(this); @@ -79,11 +108,43 @@ public class CastSettings implements Serializable { this.audioEnabled = audioEnabled; } - /** Raw user toggle; may be true on old platforms but {@link #isAudioEnabled()} stays false. */ public boolean isAudioRequested() { return audioEnabled; } + public VideoCodec getVideoCodec() { + return videoCodec; + } + + public void setVideoCodec(VideoCodec videoCodec) { + this.videoCodec = videoCodec; + } + + public AdjustmentPreset getAdjustmentPreset() { + return adjustmentPreset; + } + + public void setAdjustmentPreset(AdjustmentPreset adjustmentPreset) { + this.adjustmentPreset = adjustmentPreset; + } + + public BitrateMode getBitrateMode() { + return bitrateMode; + } + + public void setBitrateMode(BitrateMode bitrateMode) { + this.bitrateMode = bitrateMode; + } + + public NetworkAdoption getNetworkAdoption() { + return networkAdoption; + } + + public void setNetworkAdoption(NetworkAdoption networkAdoption) { + this.networkAdoption = networkAdoption; + } + + /** Legacy: raw quality bitrate. Prefer {@link CastTuningEngine#resolve}. */ public int getVideoBitrate() { return quality.videoBitrate; } @@ -103,4 +164,16 @@ public class CastSettings implements Serializable { public int getMaxWidth() { return resolution.maxWidth; } + + public boolean isPassthrough() { + return videoCodec == VideoCodec.PASSTHROUGH; + } + + public String getNegotiatedVideoMime() { + return negotiatedVideoMime; + } + + public void setNegotiatedVideoMime(String negotiatedVideoMime) { + this.negotiatedVideoMime = negotiatedVideoMime; + } } diff --git a/app/src/main/java/com/foxx/androidcast/CastTuningEngine.java b/app/src/main/java/com/foxx/androidcast/CastTuningEngine.java new file mode 100644 index 0000000..bfa8136 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/CastTuningEngine.java @@ -0,0 +1,173 @@ +package com.foxx.androidcast; + +import com.foxx.androidcast.network.control.NetworkStatsSnapshot; + +/** Resolves AUTO presets into concrete encoder/network/capture parameters. */ +public final class CastTuningEngine { + private CastTuningEngine() {} + + public static final class EffectiveParams { + public final int videoBitrate; + public final int videoFps; + public final int keyframeIntervalSec; + public final boolean requestLowLatency; + public final boolean skipTinyPFrames; + + EffectiveParams(int videoBitrate, int videoFps, int keyframeIntervalSec, + boolean requestLowLatency, boolean skipTinyPFrames) { + this.videoBitrate = videoBitrate; + this.videoFps = videoFps; + this.keyframeIntervalSec = keyframeIntervalSec; + this.requestLowLatency = requestLowLatency; + this.skipTinyPFrames = skipTinyPFrames; + } + } + + public static final class CaptureLimits { + public final int maxWidth; + public final int maxHeight; + + public CaptureLimits(int maxWidth, int maxHeight) { + this.maxWidth = maxWidth; + this.maxHeight = maxHeight; + } + } + + public static CaptureLimits resolveCaptureLimits(CastSettings settings, NetworkStatsSnapshot peer) { + CastSettings.Resolution mode = settings.getResolution(); + int capLong = 0; + int boxW = 0; + int boxH = 0; + + switch (mode) { + case HD_720P: + capLong = 720; + break; + case FIXED_1080P: + boxW = 1920; + boxH = 1080; + break; + case FIXED_720P: + boxW = 1280; + boxH = 720; + break; + case ADAPTIVE: + capLong = 720; + if (peer != null) { + if (peer.recvBitrateKbps > 0 && peer.recvBitrateKbps < 800) { + capLong = 480; + } else if (peer.recvBitrateKbps > 0 && peer.recvBitrateKbps < 1500) { + capLong = 540; + } + if (peer.rttMs > 500) { + capLong = Math.min(capLong, 540); + } + if (peer.rttMs > 800) { + capLong = Math.min(capLong, 480); + } + } + break; + default: + return new CaptureLimits(settings.getMaxWidth(), settings.getMaxHeight()); + } + + if (boxW > 0 && boxH > 0) { + return new CaptureLimits(boxW, boxH); + } + return new CaptureLimits(0, capLong); + } + + public static EffectiveParams resolve(CastSettings settings, NetworkStatsSnapshot peerStats) { + CastSettings.Quality quality = settings.getQuality(); + int bitrate = quality.videoBitrate; + int fps = quality.videoFps; + int gop = CastConfig.VIDEO_I_FRAME_INTERVAL; + boolean lowLatency = true; + boolean skipTiny = true; + + switch (settings.getAdjustmentPreset()) { + case NONE: + lowLatency = false; + skipTiny = false; + break; + case QUALITY: + bitrate = (int) (bitrate * 1.15f); + fps = Math.min(30, fps + 2); + gop = 2; + break; + case PERFORMANCE: + bitrate = (int) (bitrate * 0.85f); + fps = Math.max(24, fps); + break; + case SPEED: + case AUTO: + default: + break; + } + + switch (settings.getBitrateMode()) { + case NONE: + bitrate = Integer.MAX_VALUE / 4; + break; + case QUALITY: + bitrate = (int) (bitrate * 1.25f); + break; + case SPEED: + bitrate = (int) (bitrate * 0.65f); + fps = Math.min(fps, 24); + break; + case ROBUST_LESS: + if (CastConfig.TRANSPORT_UDP.equals(settings.getTransport())) { + bitrate = (int) (bitrate * 0.55f); + gop = 1; + } + break; + case ESTIMATED: + case BALANCED: + case AUTO: + default: + bitrate = applyBandwidthEstimate(peerStats, bitrate); + break; + } + + switch (settings.getNetworkAdoption()) { + case QUALITY: + bitrate = (int) (bitrate * 1.1f); + break; + case SPEED: + bitrate = (int) (bitrate * 0.7f); + fps = Math.min(fps, 24); + break; + case ESTIMATED: + bitrate = applyBandwidthEstimate(peerStats, bitrate); + break; + case AUTO: + default: + break; + } + + bitrate = Math.max(600_000, Math.min(16_000_000, bitrate)); + fps = Math.max(15, Math.min(30, fps)); + return new EffectiveParams(bitrate, fps, gop, lowLatency, skipTiny); + } + + private static int applyBandwidthEstimate(NetworkStatsSnapshot peer, int bitrate) { + if (peer == null) { + return bitrate; + } + if (peer.rttMs > 400) { + float factor = Math.max(0.35f, 1f - (peer.rttMs - 400) / 2000f); + bitrate = (int) (bitrate * factor); + } + if (peer.recvBitrateKbps > 0) { + int target = peer.recvBitrateKbps * 900; + if (target < bitrate) { + bitrate = Math.max(600_000, target); + } + } + if (peer.sendBitrateKbps > 0 && peer.sendBitrateKbps * 1000 < bitrate) { + bitrate = Math.max(600_000, peer.sendBitrateKbps * 950); + } + return bitrate; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/SettingsUi.java b/app/src/main/java/com/foxx/androidcast/SettingsUi.java index b00e0ad..c38b590 100644 --- a/app/src/main/java/com/foxx/androidcast/SettingsUi.java +++ b/app/src/main/java/com/foxx/androidcast/SettingsUi.java @@ -8,7 +8,10 @@ import android.widget.Spinner; import androidx.appcompat.app.AppCompatActivity; -import com.foxx.androidcast.R; +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 { @@ -18,60 +21,172 @@ public final class SettingsUi { PlatformCastSupport.applyDefaultAudioSetting(settings); Spinner transport = activity.findViewById(R.id.spinner_transport); + Spinner videoCodec = activity.findViewById(R.id.spinner_video_codec); + Spinner adjustment = activity.findViewById(R.id.spinner_adjustment); + Spinner bitrateMode = activity.findViewById(R.id.spinner_bitrate_mode); 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); - String[] transports = { + bindSpinner(activity, transport, new String[]{ activity.getString(R.string.transport_tcp), activity.getString(R.string.transport_udp) - }; - transport.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, transports)); - transport.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { - @Override - public void onItemSelected(AdapterView parent, View view, int position, long id) { - settings.setTransport(position == 1 ? CastConfig.TRANSPORT_UDP : CastConfig.TRANSPORT_TCP); + }, position -> settings.setTransport( + position == 1 ? CastConfig.TRANSPORT_UDP : CastConfig.TRANSPORT_TCP)); + + List codecs = CodecCatalog.supportedChoices(); + List codecLabels = new ArrayList<>(); + for (CastSettings.VideoCodec c : codecs) { + codecLabels.add(labelVideoCodec(activity, c)); + } + bindSpinner(activity, videoCodec, codecLabels.toArray(new String[0]), position -> { + if (position >= 0 && position < codecs.size()) { + settings.setVideoCodec(codecs.get(position)); } - - @Override - public void onNothingSelected(AdapterView parent) {} }); + selectCodecSpinner(videoCodec, codecs, settings.getVideoCodec()); - String[] qualities = { + bindEnumSpinner(activity, adjustment, CastSettings.AdjustmentPreset.values(), + SettingsUi::labelAdjustment, settings.getAdjustmentPreset().ordinal(), + p -> settings.setAdjustmentPreset(CastSettings.AdjustmentPreset.values()[p])); + + bindEnumSpinner(activity, bitrateMode, CastSettings.BitrateMode.values(), + SettingsUi::labelBitrateMode, settings.getBitrateMode().ordinal(), + p -> settings.setBitrateMode(CastSettings.BitrateMode.values()[p])); + + bindSpinner(activity, quality, new String[]{ activity.getString(R.string.quality_low), activity.getString(R.string.quality_medium), activity.getString(R.string.quality_high) - }; - quality.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, qualities)); - quality.setSelection(1); - resolution.setSelection(CastSettings.Resolution.HD_720P.ordinal()); - quality.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { - @Override - public void onItemSelected(AdapterView parent, View view, int position, long id) { - settings.setQuality(CastSettings.Quality.values()[position]); - } + }, position -> settings.setQuality(CastSettings.Quality.values()[position])); + quality.setSelection(CastSettings.Quality.MEDIUM.ordinal()); - @Override - public void onNothingSelected(AdapterView parent) {} - }); + bindEnumSpinner(activity, networkAdoption, CastSettings.NetworkAdoption.values(), + SettingsUi::labelNetworkAdoption, settings.getNetworkAdoption().ordinal(), + p -> settings.setNetworkAdoption(CastSettings.NetworkAdoption.values()[p])); String[] resolutions = { activity.getString(R.string.resolution_full), activity.getString(R.string.resolution_75), activity.getString(R.string.resolution_half), - activity.getString(R.string.resolution_720p) + activity.getString(R.string.resolution_720p), + activity.getString(R.string.resolution_fixed_1080p), + activity.getString(R.string.resolution_fixed_720p), + activity.getString(R.string.resolution_adaptive) }; - resolution.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, resolutions)); - resolution.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + bindSpinner(activity, resolution, resolutions, + position -> settings.setResolution(CastSettings.Resolution.values()[position])); + resolution.setSelection(CastSettings.Resolution.HD_720P.ordinal()); + + PlatformCastSupport.bindAudioCheckbox(activity, audio, settings); + } + + private interface IntConsumer { + void accept(int position); + } + + private interface LabelProvider { + String label(AppCompatActivity activity, T value); + } + + private static > void bindEnumSpinner(AppCompatActivity activity, Spinner spinner, + T[] values, LabelProvider labels, int selection, IntConsumer onSelect) { + String[] items = new String[values.length]; + for (int i = 0; i < values.length; i++) { + items[i] = labels.label(activity, values[i]); + } + bindSpinner(activity, spinner, items, onSelect); + if (selection >= 0 && selection < values.length) { + spinner.setSelection(selection); + } + } + + private static void bindSpinner(AppCompatActivity activity, Spinner spinner, String[] items, + IntConsumer onSelect) { + spinner.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, items)); + spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override public void onItemSelected(AdapterView parent, View view, int position, long id) { - settings.setResolution(CastSettings.Resolution.values()[position]); + onSelect.accept(position); } @Override public void onNothingSelected(AdapterView parent) {} }); + } - PlatformCastSupport.bindAudioCheckbox(activity, audio, settings); + private static void selectCodecSpinner(Spinner spinner, List codecs, + CastSettings.VideoCodec selected) { + for (int i = 0; i < codecs.size(); i++) { + if (codecs.get(i) == selected) { + spinner.setSelection(i); + return; + } + } + } + + private static String labelVideoCodec(AppCompatActivity activity, CastSettings.VideoCodec codec) { + switch (codec) { + case H264: + return activity.getString(R.string.codec_h264); + case H265: + return activity.getString(R.string.codec_h265); + case PASSTHROUGH: + return activity.getString(R.string.codec_passthrough); + case AUTO: + default: + return activity.getString(R.string.option_auto); + } + } + + private static String labelAdjustment(AppCompatActivity activity, CastSettings.AdjustmentPreset p) { + switch (p) { + case NONE: + return activity.getString(R.string.option_none); + case QUALITY: + return activity.getString(R.string.option_quality); + case SPEED: + return activity.getString(R.string.option_speed); + case PERFORMANCE: + return activity.getString(R.string.option_performance); + case AUTO: + default: + return activity.getString(R.string.option_auto); + } + } + + private static String labelBitrateMode(AppCompatActivity activity, CastSettings.BitrateMode m) { + switch (m) { + case NONE: + return activity.getString(R.string.option_none); + case QUALITY: + return activity.getString(R.string.option_quality); + case SPEED: + return activity.getString(R.string.option_speed); + case BALANCED: + return activity.getString(R.string.option_balanced); + case ROBUST_LESS: + return activity.getString(R.string.option_robust_less); + case ESTIMATED: + return activity.getString(R.string.option_estimated); + case AUTO: + default: + return activity.getString(R.string.option_auto); + } + } + + private static String labelNetworkAdoption(AppCompatActivity activity, CastSettings.NetworkAdoption n) { + switch (n) { + case QUALITY: + return activity.getString(R.string.option_quality); + case SPEED: + return activity.getString(R.string.option_speed); + case ESTIMATED: + return activity.getString(R.string.option_estimated); + case AUTO: + default: + return activity.getString(R.string.option_auto); + } } } 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 bb3383a..b16b843 100644 --- a/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java +++ b/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java @@ -53,8 +53,10 @@ public class DiscoveryManager { private final ConcurrentHashMap devices = new ConcurrentHashMap<>(); private Listener listener; + private volatile DatagramSocket socket; + private final Object lockGuard = new Object(); private WifiManager.MulticastLock multicastLock; - private DatagramSocket socket; + private int multicastHolds; public DiscoveryManager(Context context) { this.appContext = context.getApplicationContext(); @@ -71,16 +73,23 @@ public class DiscoveryManager { executor.execute(this::browseLoop); } + /** + * Stops discovery I/O. The background loop releases the multicast lock in its {@code finally} + * block — do not release here (double-release crashed the sender when cast started). + */ public void stop() { running.set(false); - if (socket != null) { - socket.close(); + DatagramSocket toClose = socket; + socket = null; + if (toClose != null) { + try { + toClose.close(); + } catch (Exception ignored) { + } } - releaseMulticastLock(); devices.clear(); } - /** Clears cached devices and notifies listener with an empty list. */ public void clearDevices() { devices.clear(); notifyListener(); @@ -115,7 +124,9 @@ public class DiscoveryManager { Log.e(TAG, "Browse failed", e); } } finally { + socket = null; releaseMulticastLock(); + running.set(false); } } @@ -142,13 +153,16 @@ public class DiscoveryManager { Log.e(TAG, "Announce failed", e); } } finally { + socket = null; releaseMulticastLock(); + running.set(false); } } private void handlePacket(DatagramPacket packet) { try { - String text = new String(packet.getData(), packet.getOffset(), packet.getLength(), StandardCharsets.UTF_8); + String text = new String(packet.getData(), packet.getOffset(), packet.getLength(), + StandardCharsets.UTF_8); JSONObject json = new JSONObject(text); if (!CastConfig.DISCOVERY_MAGIC.equals(json.optString("magic"))) { return; @@ -183,18 +197,37 @@ public class DiscoveryManager { } private void acquireMulticastLock() { - WifiManager wifi = (WifiManager) appContext.getSystemService(Context.WIFI_SERVICE); - if (wifi != null) { - multicastLock = wifi.createMulticastLock("androidcast-discovery"); - multicastLock.setReferenceCounted(true); - multicastLock.acquire(); + synchronized (lockGuard) { + if (multicastLock == null) { + WifiManager wifi = (WifiManager) appContext.getSystemService(Context.WIFI_SERVICE); + if (wifi == null) { + return; + } + multicastLock = wifi.createMulticastLock("androidcast-discovery"); + multicastLock.setReferenceCounted(false); + } + if (multicastHolds == 0) { + multicastLock.acquire(); + } + multicastHolds++; } } private void releaseMulticastLock() { - if (multicastLock != null && multicastLock.isHeld()) { - multicastLock.release(); + synchronized (lockGuard) { + if (multicastLock == null || multicastHolds <= 0) { + return; + } + multicastHolds--; + if (multicastHolds == 0) { + try { + if (multicastLock.isHeld()) { + multicastLock.release(); + } + } catch (RuntimeException e) { + Log.w(TAG, "MulticastLock release: " + e.getMessage()); + } + } } - multicastLock = null; } } diff --git a/app/src/main/java/com/foxx/androidcast/media/CodecNegotiator.java b/app/src/main/java/com/foxx/androidcast/media/CodecNegotiator.java new file mode 100644 index 0000000..5509b56 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/media/CodecNegotiator.java @@ -0,0 +1,75 @@ +package com.foxx.androidcast.media; + +import android.media.MediaFormat; + +import com.foxx.androidcast.CastSettings; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** Picks a video MIME type both sides support, honoring user preference when possible. */ +public final class CodecNegotiator { + private static final String[] PREFERENCE_ORDER = { + MediaFormat.MIMETYPE_VIDEO_HEVC, + MediaFormat.MIMETYPE_VIDEO_AVC, + MediaFormat.MIMETYPE_VIDEO_VP9, + MediaFormat.MIMETYPE_VIDEO_VP8, + }; + + private CodecNegotiator() {} + + public static String negotiate(List senderEncoders, List receiverDecoders, + CastSettings.VideoCodec preference) { + Set enc = new HashSet<>(senderEncoders); + Set dec = new HashSet<>(receiverDecoders); + if (preference == CastSettings.VideoCodec.H264) { + return pick(MediaFormat.MIMETYPE_VIDEO_AVC, enc, dec); + } + if (preference == CastSettings.VideoCodec.H265) { + return pick(MediaFormat.MIMETYPE_VIDEO_HEVC, enc, dec); + } + for (String mime : PREFERENCE_ORDER) { + if (enc.contains(mime) && dec.contains(mime)) { + return mime; + } + } + if (dec.contains(MediaFormat.MIMETYPE_VIDEO_AVC) && enc.contains(MediaFormat.MIMETYPE_VIDEO_AVC)) { + return MediaFormat.MIMETYPE_VIDEO_AVC; + } + throw new IllegalStateException("No common video codec (sender=" + enc + " receiver=" + dec + ")"); + } + + private static String pick(String mime, Set enc, Set dec) { + if (enc.contains(mime) && dec.contains(mime)) { + return mime; + } + throw new IllegalStateException("Preferred codec not available on both sides: " + mime); + } + + public static String displayName(String mime) { + if (mime == null) { + return "?"; + } + if (MediaFormat.MIMETYPE_VIDEO_AVC.equals(mime)) { + return "H.264"; + } + if (MediaFormat.MIMETYPE_VIDEO_HEVC.equals(mime)) { + return "H.265"; + } + if (MediaFormat.MIMETYPE_VIDEO_VP9.equals(mime)) { + return "VP9"; + } + if (MediaFormat.MIMETYPE_VIDEO_VP8.equals(mime)) { + return "VP8"; + } + int slash = mime.lastIndexOf('/'); + return slash >= 0 ? mime.substring(slash + 1).toUpperCase(Locale.US) : mime; + } + + public static boolean isH264(String mime) { + return MediaFormat.MIMETYPE_VIDEO_AVC.equals(mime); + } +} 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 0c02046..afa27a3 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java @@ -5,6 +5,8 @@ import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.network.control.NetworkControlAdvice; import com.foxx.androidcast.network.control.NetworkStatsSnapshot; +import android.media.MediaFormat; + import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; @@ -27,6 +29,8 @@ public final class CastProtocol { public static final byte MSG_NETWORK_STATS = 11; public static final byte MSG_NETWORK_CONTROL = 12; public static final byte MSG_AUDIO_CONSENT = 13; + public static final byte MSG_CODEC_CAPS = 14; + public static final byte MSG_CODEC_SELECTED = 15; private CastProtocol() {} @@ -64,10 +68,15 @@ public final class CastProtocol { public static byte[] castSettingsPayload(CastSettings settings) throws IOException { java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(bos); + dos.writeInt(CastSettings.SETTINGS_VERSION); dos.writeUTF(settings.getTransport()); dos.writeInt(settings.getQuality().ordinal()); dos.writeInt(settings.getResolution().ordinal()); dos.writeByte(settings.isAudioEnabled() ? 1 : 0); + dos.writeInt(settings.getVideoCodec().ordinal()); + dos.writeInt(settings.getAdjustmentPreset().ordinal()); + dos.writeInt(settings.getBitrateMode().ordinal()); + dos.writeInt(settings.getNetworkAdoption().ordinal()); dos.flush(); return bos.toByteArray(); } @@ -75,15 +84,84 @@ public final class CastProtocol { public static CastSettings parseCastSettings(byte[] payload) throws IOException { DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload)); CastSettings settings = new CastSettings(); - settings.setTransport(dis.readUTF()); - int q = dis.readInt(); - int r = dis.readInt(); - settings.setQuality(CastSettings.Quality.values()[q]); - settings.setResolution(CastSettings.Resolution.values()[r]); - settings.setAudioEnabled(dis.readByte() != 0); + dis.mark(64); + int first = dis.readInt(); + if (first == CastSettings.SETTINGS_VERSION) { + settings.setTransport(dis.readUTF()); + settings.setQuality(readEnum(CastSettings.Quality.values(), dis.readInt(), CastSettings.Quality.MEDIUM)); + settings.setResolution(readEnum(CastSettings.Resolution.values(), dis.readInt(), + CastSettings.Resolution.HD_720P)); + settings.setAudioEnabled(dis.readByte() != 0); + if (dis.available() > 0) { + settings.setVideoCodec(readEnum(CastSettings.VideoCodec.values(), dis.readInt(), + CastSettings.VideoCodec.AUTO)); + } + if (dis.available() > 0) { + settings.setAdjustmentPreset(readEnum(CastSettings.AdjustmentPreset.values(), dis.readInt(), + CastSettings.AdjustmentPreset.AUTO)); + } + if (dis.available() > 0) { + settings.setBitrateMode(readEnum(CastSettings.BitrateMode.values(), dis.readInt(), + CastSettings.BitrateMode.AUTO)); + } + if (dis.available() > 0) { + settings.setNetworkAdoption(readEnum(CastSettings.NetworkAdoption.values(), dis.readInt(), + CastSettings.NetworkAdoption.AUTO)); + } + } else { + dis.reset(); + settings.setTransport(dis.readUTF()); + settings.setQuality(readEnum(CastSettings.Quality.values(), dis.readInt(), CastSettings.Quality.MEDIUM)); + settings.setResolution(readEnum(CastSettings.Resolution.values(), dis.readInt(), + CastSettings.Resolution.HD_720P)); + settings.setAudioEnabled(dis.readByte() != 0); + } return settings; } + private static > T readEnum(T[] values, int ordinal, T fallback) { + if (ordinal >= 0 && ordinal < values.length) { + return values[ordinal]; + } + return fallback; + } + + public static byte[] codecCapsPayload(java.util.List mimeTypes) throws IOException { + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream(bos); + dos.writeInt(mimeTypes != null ? mimeTypes.size() : 0); + if (mimeTypes != null) { + for (String mime : mimeTypes) { + dos.writeUTF(mime); + } + } + dos.flush(); + return bos.toByteArray(); + } + + public static java.util.List parseCodecCaps(byte[] payload) throws IOException { + DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload)); + int n = dis.readInt(); + java.util.List list = new java.util.ArrayList<>(Math.max(0, n)); + for (int i = 0; i < n; i++) { + list.add(dis.readUTF()); + } + return list; + } + + public static byte[] codecSelectedPayload(String mime) throws IOException { + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream(bos); + dos.writeUTF(mime != null ? mime : MediaFormat.MIMETYPE_VIDEO_AVC); + dos.flush(); + return bos.toByteArray(); + } + + public static String parseCodecSelected(byte[] payload) throws IOException { + DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload)); + return dis.readUTF(); + } + public static byte[] streamConfigPayload(int width, int height, byte[] csd0, byte[] csd1) throws IOException { java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(bos); diff --git a/app/src/main/java/com/foxx/androidcast/network/CastSession.java b/app/src/main/java/com/foxx/androidcast/network/CastSession.java index 8440e90..3d97c52 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastSession.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastSession.java @@ -1,18 +1,23 @@ package com.foxx.androidcast.network; import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.media.CodecNegotiator; +import com.foxx.androidcast.sender.CodecCatalog; import java.io.IOException; +import java.util.List; /** Auth handshake and message I/O over a {@link CastTransport}. */ public class CastSession { public static final class HandshakeResult { public final String senderName; public final CastSettings settings; + public final String negotiatedVideoMime; - public HandshakeResult(String senderName, CastSettings settings) { + public HandshakeResult(String senderName, CastSettings settings, String negotiatedVideoMime) { this.senderName = senderName; this.settings = settings; + this.negotiatedVideoMime = negotiatedVideoMime; } } @@ -42,7 +47,7 @@ public class CastSession { transport.connect(host, port); } - public void clientHandshake(String deviceName, String pin, CastSettings settings) throws IOException { + public HandshakeResult clientHandshake(String deviceName, String pin, CastSettings settings) throws IOException { transport.send(CastProtocol.MSG_HELLO, CastProtocol.helloPayload(deviceName)); transport.send(CastProtocol.MSG_AUTH, CastProtocol.authPayload(pin)); CastProtocol.Message reply = transport.receive(10_000); @@ -50,11 +55,32 @@ public class CastSession { throw new IOException("Authentication failed"); } transport.send(CastProtocol.MSG_CAST_SETTINGS, CastProtocol.castSettingsPayload(settings)); + transport.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(CodecCatalog.localEncoderMimes())); + + String negotiated = null; + long deadline = System.currentTimeMillis() + 15_000; + while (System.currentTimeMillis() < deadline) { + CastProtocol.Message msg = transport.receive(2_000); + if (msg == null) { + continue; + } + if (msg.type == CastProtocol.MSG_CODEC_SELECTED) { + negotiated = CastProtocol.parseCodecSelected(msg.payload); + break; + } + } + if (negotiated == null) { + throw new IOException("Codec negotiation timeout"); + } + settings.setNegotiatedVideoMime(negotiated); + return new HandshakeResult(deviceName, settings, negotiated); } public HandshakeResult serverHandshake(String expectedPin, CastSettings localSettings) throws IOException { String senderName = "Sender"; boolean authed = false; + CastSettings remote = null; + List senderCaps = null; long deadline = System.currentTimeMillis() + 30_000; while (System.currentTimeMillis() < deadline) { CastProtocol.Message msg = transport.receive(2_000); @@ -75,18 +101,26 @@ public class CastSession { } break; case CastProtocol.MSG_CAST_SETTINGS: - if (!authed) { - break; - } - CastSettings remote = CastProtocol.parseCastSettings(msg.payload); + remote = CastProtocol.parseCastSettings(msg.payload); if (!CastProtocol.transportMatches(localSettings, remote)) { throw new IOException("Transport mismatch: receiver=" + localSettings.getTransport() + " sender=" + remote.getTransport()); } - return new HandshakeResult(senderName, remote); + break; + case CastProtocol.MSG_CODEC_CAPS: + senderCaps = CastProtocol.parseCodecCaps(msg.payload); + break; default: break; } + if (authed && remote != null && senderCaps != null) { + List receiverCaps = CodecCatalog.localDecoderMimes(); + transport.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(receiverCaps)); + String mime = CodecNegotiator.negotiate(senderCaps, receiverCaps, remote.getVideoCodec()); + transport.send(CastProtocol.MSG_CODEC_SELECTED, CastProtocol.codecSelectedPayload(mime)); + remote.setNegotiatedVideoMime(mime); + return new HandshakeResult(senderName, remote, mime); + } } throw new IOException("Handshake timeout"); } 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 87d0c7c..845c7ec 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 @@ -32,6 +32,10 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane { return outbound; } + public NetworkStatsSnapshot getLastPeerStats() { + return lastPeer != null ? lastPeer.copy() : null; + } + @Override public void onPeerStats(NetworkStatsSnapshot stats) { lastPeer = stats != null ? stats.copy() : null; 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 2e94fcb..7f55172 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java @@ -74,6 +74,7 @@ public class ReceiverCastService extends Service { private PowerManager.WakeLock wakeLock; private String activePin = com.foxx.androidcast.CastConfig.DEFAULT_PIN; private final StreamMetrics streamMetrics = new StreamMetrics(); + private String negotiatedVideoMime; private boolean streamIdle; private long lastVideoFrameMs; @@ -189,8 +190,8 @@ public class ReceiverCastService extends Service { } @Override - public void onAuthenticated(String senderName, CastSettings remoteSettings) { - mainHandler.post(() -> onSenderConnected(senderName)); + public void onAuthenticated(String senderName, CastSettings remoteSettings, String videoMime) { + mainHandler.post(() -> onSenderConnected(senderName, remoteSettings, videoMime)); } @Override @@ -271,16 +272,34 @@ public class ReceiverCastService extends Service { Log.i(TAG, "Listening PIN=" + pin + " audio=" + allowIncomingAudio); } - private void onSenderConnected(String senderName) { + private void onSenderConnected(String senderName, CastSettings remoteSettings, String videoMime) { phase = Phase.CONNECTED; + negotiatedVideoMime = videoMime; audioAccepted = null; streamIdle = false; streamMetrics.reset(); lastVideoFrameMs = 0; - updateStatus(getString(R.string.casting_from, senderName)); + String codecLabel = videoMime != null + ? com.foxx.androidcast.media.CodecNegotiator.displayName(videoMime) : "?"; + updateStatus(getString(R.string.casting_from_codec, senderName, codecLabel)); broadcastAuthenticated(senderName); openPlaybackAwaiting(R.string.playback_awaiting_stream); - Log.i(TAG, "Sender connected: " + senderName); + if (remoteSettings != null && remoteSettings.isAudioRequested() && allowIncomingAudio) { + audioAccepted = true; + sendAudioConsentAsync(true); + Log.i(TAG, "Audio accepted for sender (pre-config)"); + } else if (remoteSettings != null && remoteSettings.isAudioRequested()) { + updateStatus(getString(R.string.audio_skipped)); + } + Log.i(TAG, "Sender connected: " + senderName + " codec=" + videoMime); + } + + private void sendAudioConsentAsync(final boolean accept) { + new Thread(() -> { + if (session != null) { + session.sendAudioConsent(accept); + } + }, "AudioConsent").start(); } private void onSenderDisconnected() { @@ -502,7 +521,9 @@ public class ReceiverCastService extends Service { videoDecoder.release(); } videoDecoder = new VideoDecoder(); - videoDecoder.configure(pendingWidth, pendingHeight, pendingCsd0, pendingCsd1, attachedSurface, + String mime = negotiatedVideoMime != null ? negotiatedVideoMime + : android.media.MediaFormat.MIMETYPE_VIDEO_AVC; + videoDecoder.configure(mime, pendingWidth, pendingHeight, pendingCsd0, pendingCsd1, attachedSurface, new VideoDecoder.Listener() { @Override public void onFirstFrameRendered() { @@ -544,6 +565,7 @@ public class ReceiverCastService extends Service { private String buildDiagnosticsText() { return streamMetrics.format( settings.getTransport(), + negotiatedVideoMime, pendingWidth, pendingHeight, decoderWidth, 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 4009734..3b2cfb9 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java @@ -24,7 +24,7 @@ public class ReceiverSession { public interface Listener { void onStatus(String status); - void onAuthenticated(String senderName, CastSettings remoteSettings); + void onAuthenticated(String senderName, CastSettings remoteSettings, String negotiatedVideoMime); void onStreamConfig(int width, int height, byte[] csd0, byte[] csd1); @@ -106,7 +106,7 @@ public class ReceiverSession { networkFeedback = new NetworkFeedbackManager(session, false); session.acceptClient(); CastSession.HandshakeResult hs = session.serverHandshake(pin, localSettings); - listener.onAuthenticated(hs.senderName, hs.settings); + listener.onAuthenticated(hs.senderName, hs.settings, hs.negotiatedVideoMime); postStatus("Streaming from " + hs.senderName); readStream(); } catch (java.io.EOFException e) { 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 0545239..ee49085 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/StreamMetrics.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/StreamMetrics.java @@ -92,8 +92,8 @@ public final class StreamMetrics { return lastVideoPacketMs; } - public synchronized String format(String transport, int streamW, int streamH, int renderW, int renderH, - boolean idle) { + public synchronized String format(String transport, String videoCodec, int streamW, int streamH, int renderW, + int renderH, boolean idle) { long now = System.currentTimeMillis(); float inFps = lastSecondInFps > 0 ? lastSecondInFps : computeFps(videoPackets, firstVideoPacketMs, now); float renderFps = computeFps(renderedFrames, firstVideoPacketMs, lastRenderedFrameMs); @@ -102,6 +102,10 @@ public final class StreamMetrics { 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); @@ -122,13 +126,23 @@ public final class StreamMetrics { sb.append(" ~").append(Math.max(1, avgKb)).append(" KB/frame"); } sb.append('\n'); - if (inFps > 0 && inFps < 10f) { - sb.append("Hint: low In FPS → sender encode or TCP backlog\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).append('\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"); diff --git a/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java b/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java index 02d8579..857f825 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java @@ -5,6 +5,7 @@ import android.media.MediaFormat; import android.util.Log; import android.view.Surface; +import com.foxx.androidcast.media.CodecNegotiator; import com.foxx.androidcast.media.H264Bitstream; import java.io.IOException; @@ -12,10 +13,9 @@ import java.nio.ByteBuffer; import java.util.ArrayDeque; import java.util.Queue; -/** Thread-safe H.264 decoder feeding a Surface. */ +/** Thread-safe hardware video decoder feeding a Surface. */ public class VideoDecoder { private static final String TAG = "VideoDecoder"; - private static final String MIME = MediaFormat.MIMETYPE_VIDEO_AVC; public interface Listener { void onFirstFrameRendered(); @@ -32,26 +32,31 @@ public class VideoDecoder { private Listener listener; private boolean firstFrameNotified; - public void configure(int width, int height, byte[] csd0, byte[] csd1, Surface surface, Listener listener) - throws IOException { + private String mime = MediaFormat.MIMETYPE_VIDEO_AVC; + + public void configure(String mime, int width, int height, byte[] csd0, byte[] csd1, Surface surface, + Listener listener) throws IOException { synchronized (lock) { releaseLocked(); released = false; this.listener = listener; + this.mime = mime != null ? mime : MediaFormat.MIMETYPE_VIDEO_AVC; firstFrameNotified = false; - MediaFormat format = MediaFormat.createVideoFormat(MIME, width, height); + MediaFormat format = MediaFormat.createVideoFormat(this.mime, width, height); format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, width * height * 2); if (csd0 != null && csd0.length > 0) { - format.setByteBuffer("csd-0", ByteBuffer.wrap(H264Bitstream.toAnnexB(csd0))); + byte[] c0 = CodecNegotiator.isH264(this.mime) ? H264Bitstream.toAnnexB(csd0) : csd0; + format.setByteBuffer("csd-0", ByteBuffer.wrap(c0)); } if (csd1 != null && csd1.length > 0) { - format.setByteBuffer("csd-1", ByteBuffer.wrap(H264Bitstream.toAnnexB(csd1))); + byte[] c1 = CodecNegotiator.isH264(this.mime) ? H264Bitstream.toAnnexB(csd1) : csd1; + format.setByteBuffer("csd-1", ByteBuffer.wrap(c1)); } - codec = MediaCodec.createDecoderByType(MIME); + codec = MediaCodec.createDecoderByType(this.mime); codec.configure(format, surface, null, 0); codec.start(); configured = true; - Log.i(TAG, "Configured " + width + "x" + height); + Log.i(TAG, "Configured " + CodecNegotiator.displayName(this.mime) + " " + width + "x" + height); drainPendingLocked(); } } @@ -61,7 +66,7 @@ public class VideoDecoder { if (released || data == null || data.length == 0) { return; } - byte[] annexB = H264Bitstream.toAnnexB(data); + byte[] annexB = CodecNegotiator.isH264(mime) ? H264Bitstream.toAnnexB(data) : data; if (!configured || codec == null) { pending.add(new PendingFrame(ptsUs, keyFrame, annexB)); return; 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 0f95d59..940efa6 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java +++ b/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java @@ -42,6 +42,8 @@ public class AudioCapture { .addMatchingUsage(AudioAttributes.USAGE_MEDIA) .addMatchingUsage(AudioAttributes.USAGE_GAME) .addMatchingUsage(AudioAttributes.USAGE_UNKNOWN) + .addMatchingUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) + .addMatchingUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY) .build(); record = new AudioRecord.Builder() @@ -57,6 +59,7 @@ public class AudioCapture { running = true; record.startRecording(); + Log.i(TAG, "Playback audio capture started"); thread = new Thread(() -> readLoop(callback), "AudioCapture"); thread.start(); } @@ -69,6 +72,8 @@ public class AudioCapture { 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); } } } 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 10d763b..05675d5 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java +++ b/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java @@ -15,7 +15,6 @@ import java.util.concurrent.atomic.AtomicBoolean; public final class CastSendPump { private static final String TAG = "CastSendPump"; private static final int MAX_QUEUE = 48; - private static final int MAX_P_FRAMES_BACKLOG = 36; private final LinkedBlockingQueue queue = new LinkedBlockingQueue<>(MAX_QUEUE); private final AtomicBoolean running = new AtomicBoolean(false); @@ -68,18 +67,17 @@ public final class CastSendPump { return; } Outgoing item = Outgoing.video(ptsUs, keyFrame, annexBReadyPayload); - if (keyFrame) { - queue.offer(item); - return; - } - if (queue.size() >= MAX_P_FRAMES_BACKLOG) { - droppedPFrames++; - return; - } if (!queue.offer(item)) { - queue.poll(); + if (!keyFrame) { + Outgoing dropped = queue.poll(); + if (dropped != null && !dropped.keyFrame) { + droppedPFrames++; + } + } if (!queue.offer(item)) { - droppedPFrames++; + if (!keyFrame) { + droppedPFrames++; + } } } } diff --git a/app/src/main/java/com/foxx/androidcast/sender/CodecCatalog.java b/app/src/main/java/com/foxx/androidcast/sender/CodecCatalog.java new file mode 100644 index 0000000..4a1875b --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/sender/CodecCatalog.java @@ -0,0 +1,87 @@ +package com.foxx.androidcast.sender; + +import android.media.MediaCodecInfo; +import android.media.MediaCodecList; +import android.media.MediaFormat; +import android.os.Build; + +import com.foxx.androidcast.CastSettings; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** Discovers device video encoder/decoder MIME types. */ +public final class CodecCatalog { + private CodecCatalog() {} + + public static List supportedChoices() { + List out = new ArrayList<>(); + out.add(CastSettings.VideoCodec.AUTO); + Set enc = new LinkedHashSet<>(localEncoderMimes()); + if (enc.contains(MediaFormat.MIMETYPE_VIDEO_AVC)) { + out.add(CastSettings.VideoCodec.H264); + } + if (enc.contains(MediaFormat.MIMETYPE_VIDEO_HEVC)) { + out.add(CastSettings.VideoCodec.H265); + } + if (enc.contains(MediaFormat.MIMETYPE_VIDEO_VP8) || enc.contains(MediaFormat.MIMETYPE_VIDEO_VP9)) { + // UI lists AUTO; VP8/VP9 chosen via negotiation when both sides support + } + out.add(CastSettings.VideoCodec.PASSTHROUGH); + return out; + } + + public static List localEncoderMimes() { + return listCodecMimes(true); + } + + public static List localDecoderMimes() { + return listCodecMimes(false); + } + + private static List listCodecMimes(boolean encoder) { + Set out = new LinkedHashSet<>(); + String[] want = { + MediaFormat.MIMETYPE_VIDEO_AVC, + MediaFormat.MIMETYPE_VIDEO_HEVC, + MediaFormat.MIMETYPE_VIDEO_VP9, + MediaFormat.MIMETYPE_VIDEO_VP8, + }; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + MediaCodecList list = new MediaCodecList(MediaCodecList.REGULAR_CODECS); + for (MediaCodecInfo info : list.getCodecInfos()) { + if (info.isEncoder() != encoder) { + continue; + } + for (String type : info.getSupportedTypes()) { + for (String w : want) { + if (w.equalsIgnoreCase(type)) { + out.add(w); + } + } + } + } + } else { + int n = MediaCodecList.getCodecCount(); + for (int i = 0; i < n; i++) { + MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i); + if (info.isEncoder() != encoder) { + continue; + } + for (String type : info.getSupportedTypes()) { + for (String w : want) { + if (w.equalsIgnoreCase(type)) { + out.add(w); + } + } + } + } + } + if (out.isEmpty()) { + out.add(MediaFormat.MIMETYPE_VIDEO_AVC); + } + return new ArrayList<>(out); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/sender/EncoderKeepalive.java b/app/src/main/java/com/foxx/androidcast/sender/EncoderKeepalive.java new file mode 100644 index 0000000..74f4b3e --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/sender/EncoderKeepalive.java @@ -0,0 +1,46 @@ +package com.foxx.androidcast.sender; + +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import com.foxx.androidcast.CastConfig; + +/** + * Periodic keyframe requests so Mediatek/others don't stall on a static screen + * (encoder stops emitting; receiver hits idle overlay). + */ +public final class EncoderKeepalive { + private static final String TAG = "EncoderKeepalive"; + + private final Handler handler = new Handler(Looper.getMainLooper()); + private VideoEncoder encoder; + private boolean running; + + private final Runnable tick = new Runnable() { + @Override + public void run() { + if (!running || encoder == null) { + return; + } + encoder.requestKeyframe(); + Log.d(TAG, "Keepalive keyframe requested"); + handler.postDelayed(this, CastConfig.KEYFRAME_KEEPALIVE_MS); + } + }; + + public void start(VideoEncoder encoder) { + this.encoder = encoder; + if (running) { + return; + } + running = true; + handler.postDelayed(tick, CastConfig.KEYFRAME_KEEPALIVE_MS); + } + + public void stop() { + running = false; + handler.removeCallbacks(tick); + encoder = null; + } +} 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 b4ee14f..52a3812 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java +++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java @@ -21,8 +21,10 @@ import android.view.Surface; import android.view.WindowManager; import com.foxx.androidcast.CastNotifications; +import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.CastResolution; import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.CastTuningEngine; import com.foxx.androidcast.ICastSenderService; import com.foxx.androidcast.ICastStatusCallback; import com.foxx.androidcast.IntentExtras; @@ -31,6 +33,7 @@ import com.foxx.androidcast.network.CastProtocol; import com.foxx.androidcast.network.CastSession; import com.foxx.androidcast.network.CastTransportFactory; import com.foxx.androidcast.network.control.NetworkFeedbackManager; +import com.foxx.androidcast.network.control.NetworkStatsSnapshot; import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane; import java.io.IOException; @@ -61,6 +64,10 @@ public class ScreenCastService extends Service { private MediaProjection projection; private VirtualDisplay virtualDisplay; private VideoEncoder videoEncoder; + private EncoderKeepalive encoderKeepalive; + private CastTuningEngine.EffectiveParams effectiveParams; + private long lastSignificantFrameMs; + private final SenderStreamMetrics senderMetrics = new SenderStreamMetrics(); private AudioEncoder audioEncoder; private AudioCapture audioCapture; private CastSession session; @@ -159,7 +166,9 @@ public class ScreenCastService extends Service { updateStatus(getString(R.string.waiting_receiver, host, port)); connectWithRetry(host, port, settings); updateStatus(getString(R.string.authenticating)); - session.clientHandshake(deviceName != null ? deviceName : "Sender", pin, settings); + CastSession.HandshakeResult handshake = session.clientHandshake( + deviceName != null ? deviceName : "Sender", pin, settings); + settings = handshake.settings; networkFeedback = new NetworkFeedbackManager(session, true); PassThroughNetworkControlPlane plane = (PassThroughNetworkControlPlane) networkFeedback.getPlane(); @@ -168,6 +177,7 @@ public class ScreenCastService extends Service { casting.set(true); activeSettings = settings; startCaptureOnMainThread(resultCode, new Intent(data), settings); + scheduleTuningTick(); while (!stopping.get()) { try { if (networkFeedback != null) { @@ -288,48 +298,28 @@ public class ScreenCastService extends Service { DisplayMetrics metrics = new DisplayMetrics(); WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE); wm.getDefaultDisplay().getRealMetrics(metrics); - CastResolution.Size size = CastResolution.compute(metrics, settings); + CastResolution.Size size = CastResolution.compute(metrics, settings, getPeerStats()); setupVideoPipeline(size, settings); registerDisplayListener(); - if (settings.isAudioEnabled() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - audioEncoder = new AudioEncoder(); - audioEncoder.prepare(new AudioEncoder.Callback() { - private boolean configSent; - - @Override - public void onConfig(int sampleRate, int channels, byte[] csd0) { - if (configSent || stopping.get()) { - return; - } - configSent = true; - sendSafe(CastProtocol.MSG_AUDIO_CONFIG, - () -> CastProtocol.audioConfigPayload(sampleRate, channels, csd0)); - } - - @Override - public void onEncodedFrame(long ptsUs, byte[] frameData) { - 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); - } - }); - } + startAudioPipeline(settings); String mode = session.getTransport().getModeLabel(); updateStatus(getString(R.string.casting_active, mode, captureWidth, captureHeight)); } private void setupVideoPipeline(CastResolution.Size size, CastSettings settings) throws IOException { + if (settings.isPassthrough()) { + throw new IOException("Passthrough video is not implemented yet (debug only)"); + } if (virtualDisplay != null) { virtualDisplay.release(); virtualDisplay = null; } + if (encoderKeepalive != null) { + encoderKeepalive.stop(); + encoderKeepalive = null; + } if (videoEncoder != null) { videoEncoder.stop(); videoEncoder = null; @@ -339,6 +329,16 @@ public class ScreenCastService extends Service { captureHeight = size.height; captureDensity = size.densityDpi; + NetworkStatsSnapshot peer = null; + if (networkFeedback != null && networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) { + peer = ((PassThroughNetworkControlPlane) networkFeedback.getPlane()).getLastPeerStats(); + } + effectiveParams = CastTuningEngine.resolve(settings, peer); + String mime = settings.getNegotiatedVideoMime(); + if (mime == null || mime.isEmpty()) { + throw new IOException("No negotiated video codec"); + } + videoEncoder = new VideoEncoder(); VideoEncoder.Callback videoCb = new VideoEncoder.Callback() { @Override @@ -352,13 +352,24 @@ public class ScreenCastService extends Service { @Override public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) { - if (sendPump != null) { - sendPump.enqueueVideo(ptsUs, keyFrame, frameData); + if (sendPump == null || frameData == null) { + return; } + boolean tiny = effectiveParams != null && effectiveParams.skipTinyPFrames + && !keyFrame + && frameData.length < CastConfig.MIN_SIGNIFICANT_FRAME_BYTES; + if (tiny) { + return; + } + lastSignificantFrameMs = System.currentTimeMillis(); + senderMetrics.onEncodedFrame(keyFrame); + sendPump.enqueueVideo(ptsUs, keyFrame, frameData); } }; - Surface surface = videoEncoder.prepare(captureWidth, captureHeight, settings, videoCb); + Surface surface = videoEncoder.prepare(captureWidth, captureHeight, mime, effectiveParams, videoCb); + encoderKeepalive = new EncoderKeepalive(); + encoderKeepalive.start(videoEncoder); virtualDisplay = projection.createVirtualDisplay( "AndroidCast", captureWidth, @@ -409,7 +420,7 @@ public class ScreenCastService extends Service { DisplayMetrics metrics = new DisplayMetrics(); WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE); wm.getDefaultDisplay().getRealMetrics(metrics); - CastResolution.Size size = CastResolution.compute(metrics, activeSettings); + CastResolution.Size size = CastResolution.compute(metrics, activeSettings, getPeerStats()); if (size.width == captureWidth && size.height == captureHeight) { return; } @@ -421,8 +432,125 @@ public class ScreenCastService extends Service { } } + private NetworkStatsSnapshot getPeerStats() { + if (networkFeedback != null && networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) { + return ((PassThroughNetworkControlPlane) networkFeedback.getPlane()).getLastPeerStats(); + } + return null; + } + + private void scheduleTuningTick() { + mainHandler.postDelayed(() -> { + if (!casting.get() || stopping.get() || activeSettings == null) { + return; + } + NetworkStatsSnapshot peer = getPeerStats(); + PassThroughNetworkControlPlane plane = networkFeedback != null + && networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane + ? (PassThroughNetworkControlPlane) networkFeedback.getPlane() : null; + if (plane != null) { + NetworkStatsSnapshot local = plane.buildLocalStats(System.currentTimeMillis()); + senderMetrics.onNetworkSample(local.sendBitrateKbps, + peer != null ? peer.rttMs : 0); + } + CastTuningEngine.EffectiveParams next = CastTuningEngine.resolve(activeSettings, peer); + long idleMs = lastSignificantFrameMs > 0 + ? System.currentTimeMillis() - lastSignificantFrameMs : 0; + if (idleMs > CastConfig.KEYFRAME_KEEPALIVE_MS && videoEncoder != null) { + videoEncoder.requestKeyframe(); + } + if (effectiveParams != null && next.videoBitrate != effectiveParams.videoBitrate) { + Log.d(TAG, "Tuning bitrate " + (next.videoBitrate / 1000) + " kbps (next pipeline)"); + } + effectiveParams = next; + maybeAdaptCaptureSize(peer); + String mime = activeSettings != null ? activeSettings.getNegotiatedVideoMime() : null; + updateStatus(senderMetrics.format(mime, captureWidth, captureHeight, sendPump)); + scheduleTuningTick(); + }, 2_000); + } + + private void maybeAdaptCaptureSize(NetworkStatsSnapshot peer) { + if (activeSettings == null + || activeSettings.getResolution() != CastSettings.Resolution.ADAPTIVE) { + return; + } + DisplayMetrics metrics = new DisplayMetrics(); + WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE); + wm.getDefaultDisplay().getRealMetrics(metrics); + CastResolution.Size size = CastResolution.compute(metrics, activeSettings, peer); + int dw = Math.abs(size.width - captureWidth); + int dh = Math.abs(size.height - captureHeight); + if (dw < Math.max(32, captureWidth / 10) && dh < Math.max(32, captureHeight / 10)) { + return; + } + try { + setupVideoPipeline(size, activeSettings); + Log.i(TAG, "Adaptive capture " + captureWidth + "x" + captureHeight); + } catch (IOException e) { + Log.w(TAG, "Adaptive resize failed", e); + } + } + + private void startAudioPipeline(CastSettings settings) { + if (!settings.isAudioEnabled() || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + Log.i(TAG, "Audio capture disabled for this session"); + return; + } + if (projection == null) { + Log.w(TAG, "Audio skipped — no MediaProjection"); + return; + } + try { + if (audioEncoder != null) { + audioEncoder.stop(); + } + if (audioCapture != null) { + audioCapture.stop(); + } + audioEncoder = new AudioEncoder(); + audioEncoder.prepare(new AudioEncoder.Callback() { + private boolean configSent; + + @Override + public void onConfig(int sampleRate, int channels, byte[] csd0) { + if (configSent || stopping.get()) { + return; + } + configSent = true; + Log.i(TAG, "Sending audio config " + sampleRate + "Hz"); + sendSafe(CastProtocol.MSG_AUDIO_CONFIG, + () -> CastProtocol.audioConfigPayload(sampleRate, channels, csd0)); + } + + @Override + public void onEncodedFrame(long ptsUs, byte[] frameData) { + if (frameData == null || frameData.length == 0) { + return; + } + 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); + } + }); + Log.i(TAG, "Audio pipeline started (playback capture)"); + } catch (IOException e) { + Log.e(TAG, "Audio pipeline failed", e); + } + } + private void handleAudioConsent(boolean accept) { - if (!accept) { + if (accept) { + if (audioEncoder == null && activeSettings != null) { + startAudioPipeline(activeSettings); + } + Log.i(TAG, "Receiver accepted audio"); + } else { if (audioCapture != null) { audioCapture.stop(); audioCapture = null; @@ -475,6 +603,10 @@ public class ScreenCastService extends Service { virtualDisplay.release(); virtualDisplay = null; } + if (encoderKeepalive != null) { + encoderKeepalive.stop(); + encoderKeepalive = null; + } if (videoEncoder != null) { videoEncoder.stop(); videoEncoder = null; 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 10b6e4b..dd30d60 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java +++ b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java @@ -5,12 +5,16 @@ import android.content.Intent; import android.media.projection.MediaProjectionManager; import android.os.Build; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; 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; @@ -26,13 +30,35 @@ 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 int DEV_TAP_UNLOCK = 7; private final CastSettings castSettings = new CastSettings(); + private final Handler handler = new Handler(Looper.getMainLooper()); private DiscoveryManager discovery; private DeviceListAdapter deviceAdapter; - private EditText pinInput; private TextView statusText; + private TextView titleText; + private ProgressBar discoveryProgress; + private View advancedPanel; + private EditText pinInput; + private EditText manualHostInput; + private EditText manualPortInput; + + private int titleTapCount; + private long lastTitleTapMs; + private boolean useManualConnect; + + private final Runnable discoveryWatchdog = new Runnable() { + @Override + public void run() { + if (discovery != null) { + restartDiscoveryInternal(false); + } + handler.postDelayed(this, DISCOVERY_INTERVAL_MS); + } + }; @Override protected void onCreate(Bundle savedInstanceState) { @@ -41,15 +67,40 @@ public class SenderActivity extends AppCompatActivity { PermissionHelper.request(this); SettingsUi.bind(this, castSettings); + titleText = findViewById(R.id.text_sender_title); pinInput = findViewById(R.id.edit_pin); pinInput.setText(CastConfig.DEFAULT_PIN); statusText = findViewById(R.id.text_status); + discoveryProgress = findViewById(R.id.progress_discovery); + advancedPanel = findViewById(R.id.panel_advanced); + manualHostInput = findViewById(R.id.edit_manual_host); + manualPortInput = findViewById(R.id.edit_manual_port); + + titleText.setOnClickListener(v -> { + long now = System.currentTimeMillis(); + if (now - lastTitleTapMs > 2000) { + titleTapCount = 0; + } + lastTitleTapMs = now; + titleTapCount++; + if (titleTapCount >= DEV_TAP_UNLOCK) { + titleTapCount = 0; + advancedPanel.setVisibility( + advancedPanel.getVisibility() == View.VISIBLE ? View.GONE : View.VISIBLE); + Toast.makeText(this, + advancedPanel.getVisibility() == View.VISIBLE + ? R.string.advanced_unlocked : R.string.advanced_hidden, + Toast.LENGTH_SHORT).show(); + } + }); + ListView listView = findViewById(R.id.list_devices); listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE); deviceAdapter = new DeviceListAdapter(this); listView.setAdapter(deviceAdapter); listView.setOnItemClickListener((parent, view, position, id) -> { deviceAdapter.setSelectedPosition(position); + useManualConnect = false; DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice(); if (selected == null) { return; @@ -63,6 +114,7 @@ public class SenderActivity extends AppCompatActivity { discovery = new DiscoveryManager(this); discovery.setListener(deviceList -> { + discoveryProgress.setVisibility(View.GONE); deviceAdapter.setDevices(deviceList); statusText.setText(deviceList.isEmpty() ? getString(R.string.searching) @@ -72,16 +124,22 @@ public class SenderActivity extends AppCompatActivity { findViewById(R.id.btn_refresh).setOnClickListener(v -> restartDiscovery()); findViewById(R.id.btn_start_cast).setOnClickListener(v -> PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow)); + findViewById(R.id.btn_manual_cast).setOnClickListener(v -> { + useManualConnect = true; + PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow); + }); } @Override protected void onStart() { super.onStart(); restartDiscovery(); + handler.postDelayed(discoveryWatchdog, DISCOVERY_INTERVAL_MS); } @Override protected void onStop() { + handler.removeCallbacks(discoveryWatchdog); if (discovery != null) { discovery.stop(); } @@ -89,8 +147,15 @@ public class SenderActivity extends AppCompatActivity { } private void restartDiscovery() { + handler.removeCallbacks(discoveryWatchdog); + restartDiscoveryInternal(true); + handler.postDelayed(discoveryWatchdog, DISCOVERY_INTERVAL_MS); + } + + private void restartDiscoveryInternal(boolean userRefresh) { deviceAdapter.clearDevices(); - statusText.setText(R.string.searching); + statusText.setText(userRefresh ? R.string.rescanning : R.string.searching); + discoveryProgress.setVisibility(View.VISIBLE); if (discovery != null) { discovery.stop(); discovery.clearDevices(); @@ -99,13 +164,8 @@ public class SenderActivity extends AppCompatActivity { } private void startCastFlow() { - DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice(); - if (selected == null) { - 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 (castSettings.isPassthrough()) { + Toast.makeText(this, R.string.codec_passthrough_unavailable, Toast.LENGTH_LONG).show(); return; } String pin = pinInput.getText().toString().trim(); @@ -113,12 +173,46 @@ public class SenderActivity extends AppCompatActivity { Toast.makeText(this, R.string.enter_pin, Toast.LENGTH_SHORT).show(); return; } + + if (useManualConnect) { + String host = manualHostInput.getText().toString().trim(); + String portStr = manualPortInput.getText().toString().trim(); + if (TextUtils.isEmpty(host) || TextUtils.isEmpty(portStr)) { + Toast.makeText(this, R.string.manual_connect_missing, Toast.LENGTH_SHORT).show(); + return; + } + pendingHost = host; + try { + pendingPort = Integer.parseInt(portStr); + } catch (NumberFormatException e) { + Toast.makeText(this, R.string.manual_connect_missing, Toast.LENGTH_SHORT).show(); + return; + } + pendingDeviceName = host; + } else { + DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice(); + if (selected == null) { + 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(); + return; + } + pendingHost = selected.host; + pendingPort = selected.port; + pendingDeviceName = selected.name; + } + pendingPin = pin; MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE); startActivityForResult(mgr.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION); } private String pendingPin; + private String pendingHost; + private int pendingPort; + private String pendingDeviceName; @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { @@ -126,8 +220,7 @@ public class SenderActivity extends AppCompatActivity { if (requestCode != REQUEST_MEDIA_PROJECTION) { return; } - DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice(); - if (resultCode != Activity.RESULT_OK || data == null || selected == null) { + if (resultCode != Activity.RESULT_OK || data == null || pendingHost == null) { Toast.makeText(this, R.string.capture_denied, Toast.LENGTH_SHORT).show(); return; } @@ -139,10 +232,10 @@ public class SenderActivity extends AppCompatActivity { Intent serviceIntent = new Intent(this, ScreenCastService.class); serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_CODE, resultCode); serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_DATA, new Intent(data)); - serviceIntent.putExtra(ScreenCastService.EXTRA_HOST, selected.host); - serviceIntent.putExtra(ScreenCastService.EXTRA_PORT, selected.port); + serviceIntent.putExtra(ScreenCastService.EXTRA_HOST, pendingHost); + serviceIntent.putExtra(ScreenCastService.EXTRA_PORT, pendingPort); serviceIntent.putExtra(ScreenCastService.EXTRA_PIN, pendingPin); - serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, deviceName); + serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, pendingDeviceName != null ? pendingDeviceName : deviceName); serviceIntent.putExtra(CastSettings.EXTRA, castSettings); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderStreamMetrics.java b/app/src/main/java/com/foxx/androidcast/sender/SenderStreamMetrics.java new file mode 100644 index 0000000..9693656 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/sender/SenderStreamMetrics.java @@ -0,0 +1,65 @@ +package com.foxx.androidcast.sender; + +/** Sender-side encode/send counters for notification diagnostics. */ +public final class SenderStreamMetrics { + private long encodedFrames; + private long keyFrames; + private long windowFrames; + private long windowStartMs; + private float lastSecondFps; + private int lastSendKbps; + private int peerRttMs; + + public synchronized void onEncodedFrame(boolean keyFrame) { + long now = System.currentTimeMillis(); + encodedFrames++; + if (keyFrame) { + keyFrames++; + } + if (windowStartMs == 0) { + windowStartMs = now; + } + if (now - windowStartMs >= 1000) { + lastSecondFps = windowFrames; + windowFrames = 0; + windowStartMs = now; + } + windowFrames++; + } + + public synchronized void onNetworkSample(int sendKbps, int rttMs) { + if (sendKbps > 0) { + lastSendKbps = sendKbps; + } + if (rttMs > 0) { + peerRttMs = rttMs; + } + } + + public synchronized String format(String videoCodec, int captureW, int captureH, CastSendPump pump) { + float fps = lastSecondFps > 0 ? lastSecondFps : 0f; + StringBuilder sb = new StringBuilder(); + if (videoCodec != null && !videoCodec.isEmpty()) { + sb.append(com.foxx.androidcast.media.CodecNegotiator.displayName(videoCodec)).append(' '); + } + sb.append(captureW).append('x').append(captureH); + sb.append(" · enc ").append(fps > 0 ? String.format("%.0f", fps) : "?").append(" fps"); + if (lastSendKbps > 0) { + sb.append(" · tx ").append(lastSendKbps).append(" kbps"); + } + if (peerRttMs > 0) { + sb.append(" · rtt ").append(peerRttMs).append("ms"); + } + if (pump != null) { + int dropped = pump.getDroppedPFrames(); + int q = pump.getQueueDepth(); + if (dropped > 0 || q > 8) { + sb.append(" · q").append(q); + if (dropped > 0) { + sb.append(" drop").append(dropped); + } + } + } + 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 fa1a774..5fa6c34 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java +++ b/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java @@ -8,16 +8,14 @@ import android.os.Bundle; import android.util.Log; import android.view.Surface; -import com.foxx.androidcast.CastConfig; -import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.CastTuningEngine; import java.io.IOException; import java.nio.ByteBuffer; -/** H.264 encoder using MediaCodec (AVC), tuned for low-latency screen cast. */ +/** Hardware video encoder (H.264 / H.265) via MediaCodec. */ public class VideoEncoder { private static final String TAG = "VideoEncoder"; - private static final String MIME = MediaFormat.MIMETYPE_VIDEO_AVC; public interface Callback { void onConfig(int width, int height, byte[] csd0, byte[] csd1); @@ -32,24 +30,27 @@ public class VideoEncoder { private int height; private boolean running; private Thread drainThread; + private String mime; - public Surface prepare(int width, int height, CastSettings settings, Callback callback) throws IOException { + public Surface prepare(int width, int height, String mime, CastTuningEngine.EffectiveParams params, + Callback callback) throws IOException { this.width = width; this.height = height; + this.mime = mime; this.callback = callback; - MediaFormat tuned = buildFormat(width, height, settings, true); - MediaFormat basic = buildFormat(width, height, settings, false); + MediaFormat tuned = buildFormat(width, height, mime, params, true); + MediaFormat basic = buildFormat(width, height, mime, params, false); - codec = MediaCodec.createEncoderByType(MIME); + codec = MediaCodec.createEncoderByType(mime); if (!tryConfigure(codec, tuned)) { Log.w(TAG, "Tuned encoder format rejected; retrying with basic format"); codec.release(); - codec = MediaCodec.createEncoderByType(MIME); + codec = MediaCodec.createEncoderByType(mime); if (!tryConfigure(codec, basic)) { codec.release(); codec = null; - throw new IOException("H.264 encoder configure failed for " + width + "x" + height); + throw new IOException("Encoder configure failed for " + mime + " " + width + "x" + height); } } @@ -58,18 +59,19 @@ public class VideoEncoder { running = true; drainThread = new Thread(this::drainLoop, "VideoEncoderDrain"); drainThread.start(); - Log.i(TAG, "Encoder " + width + "x" + height + " @ " + settings.getVideoFps() + "fps " - + (settings.getVideoBitrate() / 1000) + "kbps"); + Log.i(TAG, "Encoder " + mime + " " + width + "x" + height + " @ " + params.videoFps + "fps " + + (params.videoBitrate / 1000) + "kbps GOP=" + params.keyframeIntervalSec); return inputSurface; } - private static MediaFormat buildFormat(int width, int height, CastSettings settings, boolean tuned) { - MediaFormat format = MediaFormat.createVideoFormat(MIME, width, height); + private static MediaFormat buildFormat(int width, int height, String mime, + CastTuningEngine.EffectiveParams params, boolean tuned) { + MediaFormat format = MediaFormat.createVideoFormat(mime, width, height); format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); - format.setInteger(MediaFormat.KEY_BIT_RATE, settings.getVideoBitrate()); - format.setInteger(MediaFormat.KEY_FRAME_RATE, settings.getVideoFps()); - format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, CastConfig.VIDEO_I_FRAME_INTERVAL); - if (!tuned) { + format.setInteger(MediaFormat.KEY_BIT_RATE, params.videoBitrate); + format.setInteger(MediaFormat.KEY_FRAME_RATE, params.videoFps); + format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, params.keyframeIntervalSec); + if (!tuned || !params.requestLowLatency) { return format; } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { @@ -80,7 +82,7 @@ public class VideoEncoder { format.setInteger(MediaFormat.KEY_PRIORITY, 0); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - format.setInteger(MediaFormat.KEY_OPERATING_RATE, settings.getVideoFps()); + format.setInteger(MediaFormat.KEY_OPERATING_RATE, params.videoFps); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { format.setInteger(MediaFormat.KEY_LOW_LATENCY, 1); diff --git a/app/src/main/res/layout/activity_sender.xml b/app/src/main/res/layout/activity_sender.xml index 14325b7..9ad839b 100644 --- a/app/src/main/res/layout/activity_sender.xml +++ b/app/src/main/res/layout/activity_sender.xml @@ -10,6 +10,7 @@ android:padding="16dp"> + + + + + + + + + + +