1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 06:18:42 +03:00
This commit is contained in:
Anton Afanasyeu
2026-05-15 20:37:44 +02:00
parent a0cb4325ad
commit 367a21e37a
25 changed files with 1400 additions and 179 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<CastSettings.VideoCodec> codecs = CodecCatalog.supportedChoices();
List<String> 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<T> {
String label(AppCompatActivity activity, T value);
}
private static <T extends Enum<T>> void bindEnumSpinner(AppCompatActivity activity, Spinner spinner,
T[] values, LabelProvider<T> 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<CastSettings.VideoCodec> 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);
}
}
}

View File

@@ -53,8 +53,10 @@ public class DiscoveryManager {
private final ConcurrentHashMap<String, DiscoveredDevice> 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;
}
}

View File

@@ -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<String> senderEncoders, List<String> receiverDecoders,
CastSettings.VideoCodec preference) {
Set<String> enc = new HashSet<>(senderEncoders);
Set<String> 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<String> enc, Set<String> 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);
}
}

View File

@@ -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 extends Enum<T>> 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<String> 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<String> parseCodecCaps(byte[] payload) throws IOException {
DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
int n = dis.readInt();
java.util.List<String> 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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<CastSettings.VideoCodec> supportedChoices() {
List<CastSettings.VideoCodec> out = new ArrayList<>();
out.add(CastSettings.VideoCodec.AUTO);
Set<String> 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<String> localEncoderMimes() {
return listCodecMimes(true);
}
public static List<String> localDecoderMimes() {
return listCodecMimes(false);
}
private static List<String> listCodecMimes(boolean encoder) {
Set<String> 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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@
android:padding="16dp">
<TextView
android:id="@+id/text_sender_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/sender_title"
@@ -25,6 +26,50 @@
<include layout="@layout/activity_settings_panel" />
<LinearLayout
android:id="@+id/panel_advanced"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/advanced_connect_title"
android:textStyle="bold" />
<EditText
android:id="@+id/edit_manual_host"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/manual_host_hint"
android:inputType="text" />
<EditText
android:id="@+id/edit_manual_port"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/manual_port_hint"
android:inputType="number"
android:text="41235" />
<EditText
android:id="@+id/edit_gateway_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:enabled="false"
android:hint="@string/gateway_url_hint"
android:inputType="textUri" />
<Button
android:id="@+id/btn_manual_cast"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/manual_cast" />
</LinearLayout>
<EditText
android:id="@+id/edit_pin"
android:layout_width="match_parent"
@@ -45,13 +90,27 @@
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:id="@+id/btn_refresh"
<FrameLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:text="@string/refresh" />
android:layout_weight="1">
<Button
android:id="@+id/btn_refresh"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/refresh" />
<ProgressBar
android:id="@+id/progress_discovery"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="4dp"
android:layout_gravity="bottom"
android:indeterminate="true"
android:visibility="gone" />
</FrameLayout>
<Button
android:id="@+id/btn_start_cast"

View File

@@ -22,6 +22,39 @@
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_video_codec" />
<Spinner
android:id="@+id/spinner_video_codec"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_adjustment_preset" />
<Spinner
android:id="@+id/spinner_adjustment"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_bitrate_mode" />
<Spinner
android:id="@+id/spinner_bitrate_mode"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -33,6 +66,17 @@
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_network_adoption" />
<Spinner
android:id="@+id/spinner_network_adoption"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@@ -28,7 +28,26 @@
<string name="resolution_full">Full display</string>
<string name="resolution_75">75% scale</string>
<string name="resolution_half">50% scale</string>
<string name="resolution_720p">720p (max 1280×720)</string>
<string name="resolution_720p">720p height cap (keeps aspect)</string>
<string name="resolution_fixed_1080p">Fixed 1080p (fit in 1920×1080)</string>
<string name="resolution_fixed_720p">Fixed 720p (fit in 1280×720)</string>
<string name="resolution_adaptive">Adaptive (BW estimator, preview)</string>
<string name="label_video_codec">Video codec</string>
<string name="label_adjustment_preset">Cast adjustment preset</string>
<string name="label_bitrate_mode">Bitrate</string>
<string name="label_network_adoption">Network adoption</string>
<string name="option_auto">AUTO (default)</string>
<string name="option_none">None</string>
<string name="option_quality">Quality</string>
<string name="option_speed">Speed</string>
<string name="option_performance">Performance (balanced)</string>
<string name="option_balanced">Balanced</string>
<string name="option_robust_less">RobustLess (UDP only)</string>
<string name="option_estimated">Estimated</string>
<string name="codec_h264">H.264 (AVC)</string>
<string name="codec_h265">H.265 (HEVC)</string>
<string name="codec_passthrough">Passthrough (debug)</string>
<string name="codec_passthrough_unavailable">Passthrough video is not implemented yet</string>
<string name="transport_mismatch">Transport must match the receiver (TCP/UDP)</string>
<string name="select_receiver">Select a receiver first</string>
<string name="enter_pin">Enter PIN</string>
@@ -37,6 +56,16 @@
<string name="found_receivers">Found %1$d receiver(s)</string>
<string name="receiver_ready">Ready — PIN %1$s · %2$s</string>
<string name="casting_from">Casting from %1$s</string>
<string name="casting_from_codec">Casting from %1$s · %2$s</string>
<string name="rescanning">Rescanning for receivers…</string>
<string name="advanced_unlocked">Advanced options unlocked</string>
<string name="advanced_hidden">Advanced options hidden</string>
<string name="advanced_connect_title">Advanced connect (dev)</string>
<string name="manual_host_hint">Receiver IP address</string>
<string name="manual_port_hint">Port (default 41235)</string>
<string name="gateway_url_hint">Relay gateway URL (not implemented)</string>
<string name="manual_cast">Connect to IP</string>
<string name="manual_connect_missing">Enter host and port</string>
<string name="playing_resolution">Playing %1$dx%2$d</string>
<string name="stop">Stop</string>
<string name="label_receiver_audio">Play incoming cast audio on this device</string>