1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 05:17:39 +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). */ /** Seconds between IDR frames (1 = faster recovery, slightly more bandwidth). */
public static final int VIDEO_I_FRAME_INTERVAL = 1; 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_SAMPLE_RATE = 44100;
public static final int AUDIO_CHANNEL_COUNT = 2; public static final int AUDIO_CHANNEL_COUNT = 2;
public static final int AUDIO_BITRATE = 128_000; public static final int AUDIO_BITRATE = 128_000;

View File

@@ -2,6 +2,8 @@ package com.foxx.androidcast;
import android.util.DisplayMetrics; import android.util.DisplayMetrics;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
/** Computes encoder VirtualDisplay size from display metrics and user settings. */ /** Computes encoder VirtualDisplay size from display metrics and user settings. */
public final class CastResolution { public final class CastResolution {
private CastResolution() {} private CastResolution() {}
@@ -19,26 +21,53 @@ public final class CastResolution {
} }
public static Size compute(DisplayMetrics display, CastSettings settings) { 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 displayW = display.widthPixels & ~1;
int displayH = display.heightPixels & ~1; int displayH = display.heightPixels & ~1;
float scale = settings.getResolutionScale(); float scale = settings.getResolutionScale();
int encW = Math.max(2, ((int) (displayW * scale)) & ~1); int encW = even(Math.max(2, (int) (displayW * scale)));
int encH = Math.max(2, ((int) (displayH * scale)) & ~1); int encH = even(Math.max(2, (int) (displayH * scale)));
int maxH = settings.getMaxHeight(); CastTuningEngine.CaptureLimits limits = CastTuningEngine.resolveCaptureLimits(settings, peer);
if (maxH > 0 && encH > maxH) { int maxH = limits.maxHeight > 0 ? limits.maxHeight : settings.getMaxHeight();
float ratio = (float) maxH / encH; int maxW = limits.maxWidth > 0 ? limits.maxWidth : settings.getMaxWidth();
encH = maxH & ~1;
encW = Math.max(2, ((int) (encW * ratio)) & ~1); if (maxH > 0 && maxW == 0) {
} int[] capped = capLongEdge(encW, encH, maxH);
int maxW = settings.getMaxWidth(); encW = capped[0];
if (maxW > 0 && encW > maxW) { encH = capped[1];
float ratio = (float) maxW / encW; } else {
encW = maxW & ~1; if (maxH > 0 && encH > maxH) {
encH = Math.max(2, ((int) (encH * ratio)) & ~1); 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)); int density = Math.max(120, (int) (display.densityDpi * scale));
return new Size(encW, encH, density); 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). */ /** User-selected cast parameters (passed sender → service → receiver via protocol). */
public class CastSettings implements Serializable { public class CastSettings implements Serializable {
public static final String EXTRA = "cast_settings"; public static final String EXTRA = "cast_settings";
public static final int SETTINGS_VERSION = 2;
public enum Quality { public enum Quality {
LOW(1_500_000, 24), 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 { public enum Resolution {
FULL(1.0f, 0, 0), FULL(1.0f, 0, 0),
THREE_QUARTERS(0.75f, 0, 0), THREE_QUARTERS(0.75f, 0, 0),
HALF(0.5f, 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 float scale;
public final int maxHeight; public final int maxHeight;
@@ -42,6 +65,12 @@ public class CastSettings implements Serializable {
private Quality quality = Quality.MEDIUM; private Quality quality = Quality.MEDIUM;
private Resolution resolution = Resolution.HD_720P; private Resolution resolution = Resolution.HD_720P;
private boolean audioEnabled; 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() { public CastSettings() {
PlatformCastSupport.applyDefaultAudioSetting(this); PlatformCastSupport.applyDefaultAudioSetting(this);
@@ -79,11 +108,43 @@ public class CastSettings implements Serializable {
this.audioEnabled = audioEnabled; this.audioEnabled = audioEnabled;
} }
/** Raw user toggle; may be true on old platforms but {@link #isAudioEnabled()} stays false. */
public boolean isAudioRequested() { public boolean isAudioRequested() {
return audioEnabled; 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() { public int getVideoBitrate() {
return quality.videoBitrate; return quality.videoBitrate;
} }
@@ -103,4 +164,16 @@ public class CastSettings implements Serializable {
public int getMaxWidth() { public int getMaxWidth() {
return resolution.maxWidth; 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 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}. */ /** Binds cast option spinners/checkbox to {@link CastSettings}. */
public final class SettingsUi { public final class SettingsUi {
@@ -18,60 +21,172 @@ public final class SettingsUi {
PlatformCastSupport.applyDefaultAudioSetting(settings); PlatformCastSupport.applyDefaultAudioSetting(settings);
Spinner transport = activity.findViewById(R.id.spinner_transport); 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 quality = activity.findViewById(R.id.spinner_quality);
Spinner networkAdoption = activity.findViewById(R.id.spinner_network_adoption);
Spinner resolution = activity.findViewById(R.id.spinner_resolution); Spinner resolution = activity.findViewById(R.id.spinner_resolution);
CheckBox audio = activity.findViewById(R.id.checkbox_audio); 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_tcp),
activity.getString(R.string.transport_udp) activity.getString(R.string.transport_udp)
}; }, position -> settings.setTransport(
transport.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, transports)); position == 1 ? CastConfig.TRANSPORT_UDP : CastConfig.TRANSPORT_TCP));
transport.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override List<CastSettings.VideoCodec> codecs = CodecCatalog.supportedChoices();
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) { List<String> codecLabels = new ArrayList<>();
settings.setTransport(position == 1 ? CastConfig.TRANSPORT_UDP : CastConfig.TRANSPORT_TCP); 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_low),
activity.getString(R.string.quality_medium), activity.getString(R.string.quality_medium),
activity.getString(R.string.quality_high) activity.getString(R.string.quality_high)
}; }, position -> settings.setQuality(CastSettings.Quality.values()[position]));
quality.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, qualities)); quality.setSelection(CastSettings.Quality.MEDIUM.ordinal());
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]);
}
@Override bindEnumSpinner(activity, networkAdoption, CastSettings.NetworkAdoption.values(),
public void onNothingSelected(AdapterView<?> parent) {} SettingsUi::labelNetworkAdoption, settings.getNetworkAdoption().ordinal(),
}); p -> settings.setNetworkAdoption(CastSettings.NetworkAdoption.values()[p]));
String[] resolutions = { String[] resolutions = {
activity.getString(R.string.resolution_full), activity.getString(R.string.resolution_full),
activity.getString(R.string.resolution_75), activity.getString(R.string.resolution_75),
activity.getString(R.string.resolution_half), 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)); bindSpinner(activity, resolution, resolutions,
resolution.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { 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 @Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) { public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
settings.setResolution(CastSettings.Resolution.values()[position]); onSelect.accept(position);
} }
@Override @Override
public void onNothingSelected(AdapterView<?> parent) {} 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 final ConcurrentHashMap<String, DiscoveredDevice> devices = new ConcurrentHashMap<>();
private Listener listener; private Listener listener;
private volatile DatagramSocket socket;
private final Object lockGuard = new Object();
private WifiManager.MulticastLock multicastLock; private WifiManager.MulticastLock multicastLock;
private DatagramSocket socket; private int multicastHolds;
public DiscoveryManager(Context context) { public DiscoveryManager(Context context) {
this.appContext = context.getApplicationContext(); this.appContext = context.getApplicationContext();
@@ -71,16 +73,23 @@ public class DiscoveryManager {
executor.execute(this::browseLoop); 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() { public void stop() {
running.set(false); running.set(false);
if (socket != null) { DatagramSocket toClose = socket;
socket.close(); socket = null;
if (toClose != null) {
try {
toClose.close();
} catch (Exception ignored) {
}
} }
releaseMulticastLock();
devices.clear(); devices.clear();
} }
/** Clears cached devices and notifies listener with an empty list. */
public void clearDevices() { public void clearDevices() {
devices.clear(); devices.clear();
notifyListener(); notifyListener();
@@ -115,7 +124,9 @@ public class DiscoveryManager {
Log.e(TAG, "Browse failed", e); Log.e(TAG, "Browse failed", e);
} }
} finally { } finally {
socket = null;
releaseMulticastLock(); releaseMulticastLock();
running.set(false);
} }
} }
@@ -142,13 +153,16 @@ public class DiscoveryManager {
Log.e(TAG, "Announce failed", e); Log.e(TAG, "Announce failed", e);
} }
} finally { } finally {
socket = null;
releaseMulticastLock(); releaseMulticastLock();
running.set(false);
} }
} }
private void handlePacket(DatagramPacket packet) { private void handlePacket(DatagramPacket packet) {
try { 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); JSONObject json = new JSONObject(text);
if (!CastConfig.DISCOVERY_MAGIC.equals(json.optString("magic"))) { if (!CastConfig.DISCOVERY_MAGIC.equals(json.optString("magic"))) {
return; return;
@@ -183,18 +197,37 @@ public class DiscoveryManager {
} }
private void acquireMulticastLock() { private void acquireMulticastLock() {
WifiManager wifi = (WifiManager) appContext.getSystemService(Context.WIFI_SERVICE); synchronized (lockGuard) {
if (wifi != null) { if (multicastLock == null) {
multicastLock = wifi.createMulticastLock("androidcast-discovery"); WifiManager wifi = (WifiManager) appContext.getSystemService(Context.WIFI_SERVICE);
multicastLock.setReferenceCounted(true); if (wifi == null) {
multicastLock.acquire(); return;
}
multicastLock = wifi.createMulticastLock("androidcast-discovery");
multicastLock.setReferenceCounted(false);
}
if (multicastHolds == 0) {
multicastLock.acquire();
}
multicastHolds++;
} }
} }
private void releaseMulticastLock() { private void releaseMulticastLock() {
if (multicastLock != null && multicastLock.isHeld()) { synchronized (lockGuard) {
multicastLock.release(); 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.NetworkControlAdvice;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot; import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import android.media.MediaFormat;
import java.io.DataInputStream; import java.io.DataInputStream;
import java.io.DataOutputStream; import java.io.DataOutputStream;
import java.io.IOException; 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_STATS = 11;
public static final byte MSG_NETWORK_CONTROL = 12; public static final byte MSG_NETWORK_CONTROL = 12;
public static final byte MSG_AUDIO_CONSENT = 13; public static final byte MSG_AUDIO_CONSENT = 13;
public static final byte MSG_CODEC_CAPS = 14;
public static final byte MSG_CODEC_SELECTED = 15;
private CastProtocol() {} private CastProtocol() {}
@@ -64,10 +68,15 @@ public final class CastProtocol {
public static byte[] castSettingsPayload(CastSettings settings) throws IOException { public static byte[] castSettingsPayload(CastSettings settings) throws IOException {
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos); DataOutputStream dos = new DataOutputStream(bos);
dos.writeInt(CastSettings.SETTINGS_VERSION);
dos.writeUTF(settings.getTransport()); dos.writeUTF(settings.getTransport());
dos.writeInt(settings.getQuality().ordinal()); dos.writeInt(settings.getQuality().ordinal());
dos.writeInt(settings.getResolution().ordinal()); dos.writeInt(settings.getResolution().ordinal());
dos.writeByte(settings.isAudioEnabled() ? 1 : 0); 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(); dos.flush();
return bos.toByteArray(); return bos.toByteArray();
} }
@@ -75,15 +84,84 @@ public final class CastProtocol {
public static CastSettings parseCastSettings(byte[] payload) throws IOException { public static CastSettings parseCastSettings(byte[] payload) throws IOException {
DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload)); DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
CastSettings settings = new CastSettings(); CastSettings settings = new CastSettings();
settings.setTransport(dis.readUTF()); dis.mark(64);
int q = dis.readInt(); int first = dis.readInt();
int r = dis.readInt(); if (first == CastSettings.SETTINGS_VERSION) {
settings.setQuality(CastSettings.Quality.values()[q]); settings.setTransport(dis.readUTF());
settings.setResolution(CastSettings.Resolution.values()[r]); settings.setQuality(readEnum(CastSettings.Quality.values(), dis.readInt(), CastSettings.Quality.MEDIUM));
settings.setAudioEnabled(dis.readByte() != 0); 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; 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 { public static byte[] streamConfigPayload(int width, int height, byte[] csd0, byte[] csd1) throws IOException {
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos); DataOutputStream dos = new DataOutputStream(bos);

View File

@@ -1,18 +1,23 @@
package com.foxx.androidcast.network; package com.foxx.androidcast.network;
import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.media.CodecNegotiator;
import com.foxx.androidcast.sender.CodecCatalog;
import java.io.IOException; import java.io.IOException;
import java.util.List;
/** Auth handshake and message I/O over a {@link CastTransport}. */ /** Auth handshake and message I/O over a {@link CastTransport}. */
public class CastSession { public class CastSession {
public static final class HandshakeResult { public static final class HandshakeResult {
public final String senderName; public final String senderName;
public final CastSettings settings; 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.senderName = senderName;
this.settings = settings; this.settings = settings;
this.negotiatedVideoMime = negotiatedVideoMime;
} }
} }
@@ -42,7 +47,7 @@ public class CastSession {
transport.connect(host, port); 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_HELLO, CastProtocol.helloPayload(deviceName));
transport.send(CastProtocol.MSG_AUTH, CastProtocol.authPayload(pin)); transport.send(CastProtocol.MSG_AUTH, CastProtocol.authPayload(pin));
CastProtocol.Message reply = transport.receive(10_000); CastProtocol.Message reply = transport.receive(10_000);
@@ -50,11 +55,32 @@ public class CastSession {
throw new IOException("Authentication failed"); throw new IOException("Authentication failed");
} }
transport.send(CastProtocol.MSG_CAST_SETTINGS, CastProtocol.castSettingsPayload(settings)); 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 { public HandshakeResult serverHandshake(String expectedPin, CastSettings localSettings) throws IOException {
String senderName = "Sender"; String senderName = "Sender";
boolean authed = false; boolean authed = false;
CastSettings remote = null;
List<String> senderCaps = null;
long deadline = System.currentTimeMillis() + 30_000; long deadline = System.currentTimeMillis() + 30_000;
while (System.currentTimeMillis() < deadline) { while (System.currentTimeMillis() < deadline) {
CastProtocol.Message msg = transport.receive(2_000); CastProtocol.Message msg = transport.receive(2_000);
@@ -75,18 +101,26 @@ public class CastSession {
} }
break; break;
case CastProtocol.MSG_CAST_SETTINGS: case CastProtocol.MSG_CAST_SETTINGS:
if (!authed) { remote = CastProtocol.parseCastSettings(msg.payload);
break;
}
CastSettings remote = CastProtocol.parseCastSettings(msg.payload);
if (!CastProtocol.transportMatches(localSettings, remote)) { if (!CastProtocol.transportMatches(localSettings, remote)) {
throw new IOException("Transport mismatch: receiver=" throw new IOException("Transport mismatch: receiver="
+ localSettings.getTransport() + " sender=" + remote.getTransport()); + localSettings.getTransport() + " sender=" + remote.getTransport());
} }
return new HandshakeResult(senderName, remote); break;
case CastProtocol.MSG_CODEC_CAPS:
senderCaps = CastProtocol.parseCodecCaps(msg.payload);
break;
default: default:
break; 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"); throw new IOException("Handshake timeout");
} }

View File

@@ -32,6 +32,10 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
return outbound; return outbound;
} }
public NetworkStatsSnapshot getLastPeerStats() {
return lastPeer != null ? lastPeer.copy() : null;
}
@Override @Override
public void onPeerStats(NetworkStatsSnapshot stats) { public void onPeerStats(NetworkStatsSnapshot stats) {
lastPeer = stats != null ? stats.copy() : null; lastPeer = stats != null ? stats.copy() : null;

View File

@@ -74,6 +74,7 @@ public class ReceiverCastService extends Service {
private PowerManager.WakeLock wakeLock; private PowerManager.WakeLock wakeLock;
private String activePin = com.foxx.androidcast.CastConfig.DEFAULT_PIN; private String activePin = com.foxx.androidcast.CastConfig.DEFAULT_PIN;
private final StreamMetrics streamMetrics = new StreamMetrics(); private final StreamMetrics streamMetrics = new StreamMetrics();
private String negotiatedVideoMime;
private boolean streamIdle; private boolean streamIdle;
private long lastVideoFrameMs; private long lastVideoFrameMs;
@@ -189,8 +190,8 @@ public class ReceiverCastService extends Service {
} }
@Override @Override
public void onAuthenticated(String senderName, CastSettings remoteSettings) { public void onAuthenticated(String senderName, CastSettings remoteSettings, String videoMime) {
mainHandler.post(() -> onSenderConnected(senderName)); mainHandler.post(() -> onSenderConnected(senderName, remoteSettings, videoMime));
} }
@Override @Override
@@ -271,16 +272,34 @@ public class ReceiverCastService extends Service {
Log.i(TAG, "Listening PIN=" + pin + " audio=" + allowIncomingAudio); 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; phase = Phase.CONNECTED;
negotiatedVideoMime = videoMime;
audioAccepted = null; audioAccepted = null;
streamIdle = false; streamIdle = false;
streamMetrics.reset(); streamMetrics.reset();
lastVideoFrameMs = 0; 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); broadcastAuthenticated(senderName);
openPlaybackAwaiting(R.string.playback_awaiting_stream); 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() { private void onSenderDisconnected() {
@@ -502,7 +521,9 @@ public class ReceiverCastService extends Service {
videoDecoder.release(); videoDecoder.release();
} }
videoDecoder = new VideoDecoder(); 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() { new VideoDecoder.Listener() {
@Override @Override
public void onFirstFrameRendered() { public void onFirstFrameRendered() {
@@ -544,6 +565,7 @@ public class ReceiverCastService extends Service {
private String buildDiagnosticsText() { private String buildDiagnosticsText() {
return streamMetrics.format( return streamMetrics.format(
settings.getTransport(), settings.getTransport(),
negotiatedVideoMime,
pendingWidth, pendingWidth,
pendingHeight, pendingHeight,
decoderWidth, decoderWidth,

View File

@@ -24,7 +24,7 @@ public class ReceiverSession {
public interface Listener { public interface Listener {
void onStatus(String status); 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); void onStreamConfig(int width, int height, byte[] csd0, byte[] csd1);
@@ -106,7 +106,7 @@ public class ReceiverSession {
networkFeedback = new NetworkFeedbackManager(session, false); networkFeedback = new NetworkFeedbackManager(session, false);
session.acceptClient(); session.acceptClient();
CastSession.HandshakeResult hs = session.serverHandshake(pin, localSettings); 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); postStatus("Streaming from " + hs.senderName);
readStream(); readStream();
} catch (java.io.EOFException e) { } catch (java.io.EOFException e) {

View File

@@ -92,8 +92,8 @@ public final class StreamMetrics {
return lastVideoPacketMs; return lastVideoPacketMs;
} }
public synchronized String format(String transport, int streamW, int streamH, int renderW, int renderH, public synchronized String format(String transport, String videoCodec, int streamW, int streamH, int renderW,
boolean idle) { int renderH, boolean idle) {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
float inFps = lastSecondInFps > 0 ? lastSecondInFps : computeFps(videoPackets, firstVideoPacketMs, now); float inFps = lastSecondInFps > 0 ? lastSecondInFps : computeFps(videoPackets, firstVideoPacketMs, now);
float renderFps = computeFps(renderedFrames, firstVideoPacketMs, lastRenderedFrameMs); float renderFps = computeFps(renderedFrames, firstVideoPacketMs, lastRenderedFrameMs);
@@ -102,6 +102,10 @@ public final class StreamMetrics {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append("Transport: ").append(transport != null ? transport.toUpperCase() : "?").append('\n'); 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); sb.append("Stream: ").append(streamW).append('x').append(streamH);
if (renderW > 0 && renderH > 0 && (renderW != streamW || renderH != streamH)) { if (renderW > 0 && renderH > 0 && (renderW != streamW || renderH != streamH)) {
sb.append(" render: ").append(renderW).append('x').append(renderH); 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(" ~").append(Math.max(1, avgKb)).append(" KB/frame");
} }
sb.append('\n'); sb.append('\n');
if (inFps > 0 && inFps < 10f) { if (inFps > 0 && inFps < 8f) {
sb.append("Hint: low In FPS → sender encode or TCP backlog\n"); 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) { if (renderFps > 0 && inFps > renderFps + 3f) {
sb.append("Hint: decode slower than network\n"); 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("Video bytes: ").append(formatBytes(videoBytes));
sb.append(" Audio bytes: ").append(formatBytes(audioBytes)).append('\n'); sb.append(" Audio bytes: ").append(formatBytes(audioBytes)).append('\n');
sb.append("State: ").append(idle ? "idle (awaiting)" : "streaming"); sb.append("State: ").append(idle ? "idle (awaiting)" : "streaming");

View File

@@ -5,6 +5,7 @@ import android.media.MediaFormat;
import android.util.Log; import android.util.Log;
import android.view.Surface; import android.view.Surface;
import com.foxx.androidcast.media.CodecNegotiator;
import com.foxx.androidcast.media.H264Bitstream; import com.foxx.androidcast.media.H264Bitstream;
import java.io.IOException; import java.io.IOException;
@@ -12,10 +13,9 @@ import java.nio.ByteBuffer;
import java.util.ArrayDeque; import java.util.ArrayDeque;
import java.util.Queue; import java.util.Queue;
/** Thread-safe H.264 decoder feeding a Surface. */ /** Thread-safe hardware video decoder feeding a Surface. */
public class VideoDecoder { public class VideoDecoder {
private static final String TAG = "VideoDecoder"; private static final String TAG = "VideoDecoder";
private static final String MIME = MediaFormat.MIMETYPE_VIDEO_AVC;
public interface Listener { public interface Listener {
void onFirstFrameRendered(); void onFirstFrameRendered();
@@ -32,26 +32,31 @@ public class VideoDecoder {
private Listener listener; private Listener listener;
private boolean firstFrameNotified; private boolean firstFrameNotified;
public void configure(int width, int height, byte[] csd0, byte[] csd1, Surface surface, Listener listener) private String mime = MediaFormat.MIMETYPE_VIDEO_AVC;
throws IOException {
public void configure(String mime, int width, int height, byte[] csd0, byte[] csd1, Surface surface,
Listener listener) throws IOException {
synchronized (lock) { synchronized (lock) {
releaseLocked(); releaseLocked();
released = false; released = false;
this.listener = listener; this.listener = listener;
this.mime = mime != null ? mime : MediaFormat.MIMETYPE_VIDEO_AVC;
firstFrameNotified = false; 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); format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, width * height * 2);
if (csd0 != null && csd0.length > 0) { 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) { 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.configure(format, surface, null, 0);
codec.start(); codec.start();
configured = true; configured = true;
Log.i(TAG, "Configured " + width + "x" + height); Log.i(TAG, "Configured " + CodecNegotiator.displayName(this.mime) + " " + width + "x" + height);
drainPendingLocked(); drainPendingLocked();
} }
} }
@@ -61,7 +66,7 @@ public class VideoDecoder {
if (released || data == null || data.length == 0) { if (released || data == null || data.length == 0) {
return; return;
} }
byte[] annexB = H264Bitstream.toAnnexB(data); byte[] annexB = CodecNegotiator.isH264(mime) ? H264Bitstream.toAnnexB(data) : data;
if (!configured || codec == null) { if (!configured || codec == null) {
pending.add(new PendingFrame(ptsUs, keyFrame, annexB)); pending.add(new PendingFrame(ptsUs, keyFrame, annexB));
return; return;

View File

@@ -42,6 +42,8 @@ public class AudioCapture {
.addMatchingUsage(AudioAttributes.USAGE_MEDIA) .addMatchingUsage(AudioAttributes.USAGE_MEDIA)
.addMatchingUsage(AudioAttributes.USAGE_GAME) .addMatchingUsage(AudioAttributes.USAGE_GAME)
.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN) .addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
.addMatchingUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
.addMatchingUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY)
.build(); .build();
record = new AudioRecord.Builder() record = new AudioRecord.Builder()
@@ -57,6 +59,7 @@ public class AudioCapture {
running = true; running = true;
record.startRecording(); record.startRecording();
Log.i(TAG, "Playback audio capture started");
thread = new Thread(() -> readLoop(callback), "AudioCapture"); thread = new Thread(() -> readLoop(callback), "AudioCapture");
thread.start(); thread.start();
} }
@@ -69,6 +72,8 @@ public class AudioCapture {
if (read > 0) { if (read > 0) {
long ptsUs = (System.nanoTime() - startNs) / 1000; long ptsUs = (System.nanoTime() - startNs) / 1000;
callback.onPcm(buf, read, ptsUs); 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 { public final class CastSendPump {
private static final String TAG = "CastSendPump"; private static final String TAG = "CastSendPump";
private static final int MAX_QUEUE = 48; 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 LinkedBlockingQueue<Outgoing> queue = new LinkedBlockingQueue<>(MAX_QUEUE);
private final AtomicBoolean running = new AtomicBoolean(false); private final AtomicBoolean running = new AtomicBoolean(false);
@@ -68,18 +67,17 @@ public final class CastSendPump {
return; return;
} }
Outgoing item = Outgoing.video(ptsUs, keyFrame, annexBReadyPayload); 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)) { if (!queue.offer(item)) {
queue.poll(); if (!keyFrame) {
Outgoing dropped = queue.poll();
if (dropped != null && !dropped.keyFrame) {
droppedPFrames++;
}
}
if (!queue.offer(item)) { 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 android.view.WindowManager;
import com.foxx.androidcast.CastNotifications; import com.foxx.androidcast.CastNotifications;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastResolution; import com.foxx.androidcast.CastResolution;
import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.CastTuningEngine;
import com.foxx.androidcast.ICastSenderService; import com.foxx.androidcast.ICastSenderService;
import com.foxx.androidcast.ICastStatusCallback; import com.foxx.androidcast.ICastStatusCallback;
import com.foxx.androidcast.IntentExtras; 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.CastSession;
import com.foxx.androidcast.network.CastTransportFactory; import com.foxx.androidcast.network.CastTransportFactory;
import com.foxx.androidcast.network.control.NetworkFeedbackManager; import com.foxx.androidcast.network.control.NetworkFeedbackManager;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane; import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
import java.io.IOException; import java.io.IOException;
@@ -61,6 +64,10 @@ public class ScreenCastService extends Service {
private MediaProjection projection; private MediaProjection projection;
private VirtualDisplay virtualDisplay; private VirtualDisplay virtualDisplay;
private VideoEncoder videoEncoder; private VideoEncoder videoEncoder;
private EncoderKeepalive encoderKeepalive;
private CastTuningEngine.EffectiveParams effectiveParams;
private long lastSignificantFrameMs;
private final SenderStreamMetrics senderMetrics = new SenderStreamMetrics();
private AudioEncoder audioEncoder; private AudioEncoder audioEncoder;
private AudioCapture audioCapture; private AudioCapture audioCapture;
private CastSession session; private CastSession session;
@@ -159,7 +166,9 @@ public class ScreenCastService extends Service {
updateStatus(getString(R.string.waiting_receiver, host, port)); updateStatus(getString(R.string.waiting_receiver, host, port));
connectWithRetry(host, port, settings); connectWithRetry(host, port, settings);
updateStatus(getString(R.string.authenticating)); 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); networkFeedback = new NetworkFeedbackManager(session, true);
PassThroughNetworkControlPlane plane = PassThroughNetworkControlPlane plane =
(PassThroughNetworkControlPlane) networkFeedback.getPlane(); (PassThroughNetworkControlPlane) networkFeedback.getPlane();
@@ -168,6 +177,7 @@ public class ScreenCastService extends Service {
casting.set(true); casting.set(true);
activeSettings = settings; activeSettings = settings;
startCaptureOnMainThread(resultCode, new Intent(data), settings); startCaptureOnMainThread(resultCode, new Intent(data), settings);
scheduleTuningTick();
while (!stopping.get()) { while (!stopping.get()) {
try { try {
if (networkFeedback != null) { if (networkFeedback != null) {
@@ -288,48 +298,28 @@ public class ScreenCastService extends Service {
DisplayMetrics metrics = new DisplayMetrics(); DisplayMetrics metrics = new DisplayMetrics();
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE); WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
wm.getDefaultDisplay().getRealMetrics(metrics); wm.getDefaultDisplay().getRealMetrics(metrics);
CastResolution.Size size = CastResolution.compute(metrics, settings); CastResolution.Size size = CastResolution.compute(metrics, settings, getPeerStats());
setupVideoPipeline(size, settings); setupVideoPipeline(size, settings);
registerDisplayListener(); registerDisplayListener();
if (settings.isAudioEnabled() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startAudioPipeline(settings);
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);
}
});
}
String mode = session.getTransport().getModeLabel(); String mode = session.getTransport().getModeLabel();
updateStatus(getString(R.string.casting_active, mode, captureWidth, captureHeight)); updateStatus(getString(R.string.casting_active, mode, captureWidth, captureHeight));
} }
private void setupVideoPipeline(CastResolution.Size size, CastSettings settings) throws IOException { 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) { if (virtualDisplay != null) {
virtualDisplay.release(); virtualDisplay.release();
virtualDisplay = null; virtualDisplay = null;
} }
if (encoderKeepalive != null) {
encoderKeepalive.stop();
encoderKeepalive = null;
}
if (videoEncoder != null) { if (videoEncoder != null) {
videoEncoder.stop(); videoEncoder.stop();
videoEncoder = null; videoEncoder = null;
@@ -339,6 +329,16 @@ public class ScreenCastService extends Service {
captureHeight = size.height; captureHeight = size.height;
captureDensity = size.densityDpi; 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 = new VideoEncoder();
VideoEncoder.Callback videoCb = new VideoEncoder.Callback() { VideoEncoder.Callback videoCb = new VideoEncoder.Callback() {
@Override @Override
@@ -352,13 +352,24 @@ public class ScreenCastService extends Service {
@Override @Override
public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) { public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
if (sendPump != null) { if (sendPump == null || frameData == null) {
sendPump.enqueueVideo(ptsUs, keyFrame, frameData); 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( virtualDisplay = projection.createVirtualDisplay(
"AndroidCast", "AndroidCast",
captureWidth, captureWidth,
@@ -409,7 +420,7 @@ public class ScreenCastService extends Service {
DisplayMetrics metrics = new DisplayMetrics(); DisplayMetrics metrics = new DisplayMetrics();
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE); WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
wm.getDefaultDisplay().getRealMetrics(metrics); 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) { if (size.width == captureWidth && size.height == captureHeight) {
return; 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) { 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) { if (audioCapture != null) {
audioCapture.stop(); audioCapture.stop();
audioCapture = null; audioCapture = null;
@@ -475,6 +603,10 @@ public class ScreenCastService extends Service {
virtualDisplay.release(); virtualDisplay.release();
virtualDisplay = null; virtualDisplay = null;
} }
if (encoderKeepalive != null) {
encoderKeepalive.stop();
encoderKeepalive = null;
}
if (videoEncoder != null) { if (videoEncoder != null) {
videoEncoder.stop(); videoEncoder.stop();
videoEncoder = null; videoEncoder = null;

View File

@@ -5,12 +5,16 @@ import android.content.Intent;
import android.media.projection.MediaProjectionManager; import android.media.projection.MediaProjectionManager;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.provider.Settings; import android.provider.Settings;
import android.text.TextUtils; import android.text.TextUtils;
import android.view.View;
import android.view.WindowManager; import android.view.WindowManager;
import android.widget.Button; import android.widget.Button;
import android.widget.EditText; import android.widget.EditText;
import android.widget.ListView; import android.widget.ListView;
import android.widget.ProgressBar;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
@@ -26,13 +30,35 @@ import com.foxx.androidcast.discovery.DiscoveryManager;
public class SenderActivity extends AppCompatActivity { public class SenderActivity extends AppCompatActivity {
private static final int REQUEST_MEDIA_PROJECTION = 2001; private static final int REQUEST_MEDIA_PROJECTION = 2001;
private static final long DISCOVERY_INTERVAL_MS = 30_000;
private static final int DEV_TAP_UNLOCK = 7;
private final CastSettings castSettings = new CastSettings(); private final CastSettings castSettings = new CastSettings();
private final Handler handler = new Handler(Looper.getMainLooper());
private DiscoveryManager discovery; private DiscoveryManager discovery;
private DeviceListAdapter deviceAdapter; private DeviceListAdapter deviceAdapter;
private EditText pinInput;
private TextView statusText; 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 @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
@@ -41,15 +67,40 @@ public class SenderActivity extends AppCompatActivity {
PermissionHelper.request(this); PermissionHelper.request(this);
SettingsUi.bind(this, castSettings); SettingsUi.bind(this, castSettings);
titleText = findViewById(R.id.text_sender_title);
pinInput = findViewById(R.id.edit_pin); pinInput = findViewById(R.id.edit_pin);
pinInput.setText(CastConfig.DEFAULT_PIN); pinInput.setText(CastConfig.DEFAULT_PIN);
statusText = findViewById(R.id.text_status); 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 listView = findViewById(R.id.list_devices);
listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE); listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
deviceAdapter = new DeviceListAdapter(this); deviceAdapter = new DeviceListAdapter(this);
listView.setAdapter(deviceAdapter); listView.setAdapter(deviceAdapter);
listView.setOnItemClickListener((parent, view, position, id) -> { listView.setOnItemClickListener((parent, view, position, id) -> {
deviceAdapter.setSelectedPosition(position); deviceAdapter.setSelectedPosition(position);
useManualConnect = false;
DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice(); DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice();
if (selected == null) { if (selected == null) {
return; return;
@@ -63,6 +114,7 @@ public class SenderActivity extends AppCompatActivity {
discovery = new DiscoveryManager(this); discovery = new DiscoveryManager(this);
discovery.setListener(deviceList -> { discovery.setListener(deviceList -> {
discoveryProgress.setVisibility(View.GONE);
deviceAdapter.setDevices(deviceList); deviceAdapter.setDevices(deviceList);
statusText.setText(deviceList.isEmpty() statusText.setText(deviceList.isEmpty()
? getString(R.string.searching) ? 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_refresh).setOnClickListener(v -> restartDiscovery());
findViewById(R.id.btn_start_cast).setOnClickListener(v -> findViewById(R.id.btn_start_cast).setOnClickListener(v ->
PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow)); PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow));
findViewById(R.id.btn_manual_cast).setOnClickListener(v -> {
useManualConnect = true;
PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow);
});
} }
@Override @Override
protected void onStart() { protected void onStart() {
super.onStart(); super.onStart();
restartDiscovery(); restartDiscovery();
handler.postDelayed(discoveryWatchdog, DISCOVERY_INTERVAL_MS);
} }
@Override @Override
protected void onStop() { protected void onStop() {
handler.removeCallbacks(discoveryWatchdog);
if (discovery != null) { if (discovery != null) {
discovery.stop(); discovery.stop();
} }
@@ -89,8 +147,15 @@ public class SenderActivity extends AppCompatActivity {
} }
private void restartDiscovery() { private void restartDiscovery() {
handler.removeCallbacks(discoveryWatchdog);
restartDiscoveryInternal(true);
handler.postDelayed(discoveryWatchdog, DISCOVERY_INTERVAL_MS);
}
private void restartDiscoveryInternal(boolean userRefresh) {
deviceAdapter.clearDevices(); deviceAdapter.clearDevices();
statusText.setText(R.string.searching); statusText.setText(userRefresh ? R.string.rescanning : R.string.searching);
discoveryProgress.setVisibility(View.VISIBLE);
if (discovery != null) { if (discovery != null) {
discovery.stop(); discovery.stop();
discovery.clearDevices(); discovery.clearDevices();
@@ -99,13 +164,8 @@ public class SenderActivity extends AppCompatActivity {
} }
private void startCastFlow() { private void startCastFlow() {
DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice(); if (castSettings.isPassthrough()) {
if (selected == null) { Toast.makeText(this, R.string.codec_passthrough_unavailable, Toast.LENGTH_LONG).show();
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; return;
} }
String pin = pinInput.getText().toString().trim(); 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(); Toast.makeText(this, R.string.enter_pin, Toast.LENGTH_SHORT).show();
return; 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; pendingPin = pin;
MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE); MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
startActivityForResult(mgr.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION); startActivityForResult(mgr.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION);
} }
private String pendingPin; private String pendingPin;
private String pendingHost;
private int pendingPort;
private String pendingDeviceName;
@Override @Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) { protected void onActivityResult(int requestCode, int resultCode, Intent data) {
@@ -126,8 +220,7 @@ public class SenderActivity extends AppCompatActivity {
if (requestCode != REQUEST_MEDIA_PROJECTION) { if (requestCode != REQUEST_MEDIA_PROJECTION) {
return; return;
} }
DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice(); if (resultCode != Activity.RESULT_OK || data == null || pendingHost == null) {
if (resultCode != Activity.RESULT_OK || data == null || selected == null) {
Toast.makeText(this, R.string.capture_denied, Toast.LENGTH_SHORT).show(); Toast.makeText(this, R.string.capture_denied, Toast.LENGTH_SHORT).show();
return; return;
} }
@@ -139,10 +232,10 @@ public class SenderActivity extends AppCompatActivity {
Intent serviceIntent = new Intent(this, ScreenCastService.class); Intent serviceIntent = new Intent(this, ScreenCastService.class);
serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_CODE, resultCode); serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_CODE, resultCode);
serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_DATA, new Intent(data)); serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_DATA, new Intent(data));
serviceIntent.putExtra(ScreenCastService.EXTRA_HOST, selected.host); serviceIntent.putExtra(ScreenCastService.EXTRA_HOST, pendingHost);
serviceIntent.putExtra(ScreenCastService.EXTRA_PORT, selected.port); serviceIntent.putExtra(ScreenCastService.EXTRA_PORT, pendingPort);
serviceIntent.putExtra(ScreenCastService.EXTRA_PIN, pendingPin); 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); serviceIntent.putExtra(CastSettings.EXTRA, castSettings);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 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.util.Log;
import android.view.Surface; import android.view.Surface;
import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.CastTuningEngine;
import com.foxx.androidcast.CastSettings;
import java.io.IOException; import java.io.IOException;
import java.nio.ByteBuffer; 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 { public class VideoEncoder {
private static final String TAG = "VideoEncoder"; private static final String TAG = "VideoEncoder";
private static final String MIME = MediaFormat.MIMETYPE_VIDEO_AVC;
public interface Callback { public interface Callback {
void onConfig(int width, int height, byte[] csd0, byte[] csd1); void onConfig(int width, int height, byte[] csd0, byte[] csd1);
@@ -32,24 +30,27 @@ public class VideoEncoder {
private int height; private int height;
private boolean running; private boolean running;
private Thread drainThread; 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.width = width;
this.height = height; this.height = height;
this.mime = mime;
this.callback = callback; this.callback = callback;
MediaFormat tuned = buildFormat(width, height, settings, true); MediaFormat tuned = buildFormat(width, height, mime, params, true);
MediaFormat basic = buildFormat(width, height, settings, false); MediaFormat basic = buildFormat(width, height, mime, params, false);
codec = MediaCodec.createEncoderByType(MIME); codec = MediaCodec.createEncoderByType(mime);
if (!tryConfigure(codec, tuned)) { if (!tryConfigure(codec, tuned)) {
Log.w(TAG, "Tuned encoder format rejected; retrying with basic format"); Log.w(TAG, "Tuned encoder format rejected; retrying with basic format");
codec.release(); codec.release();
codec = MediaCodec.createEncoderByType(MIME); codec = MediaCodec.createEncoderByType(mime);
if (!tryConfigure(codec, basic)) { if (!tryConfigure(codec, basic)) {
codec.release(); codec.release();
codec = null; 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; running = true;
drainThread = new Thread(this::drainLoop, "VideoEncoderDrain"); drainThread = new Thread(this::drainLoop, "VideoEncoderDrain");
drainThread.start(); drainThread.start();
Log.i(TAG, "Encoder " + width + "x" + height + " @ " + settings.getVideoFps() + "fps " Log.i(TAG, "Encoder " + mime + " " + width + "x" + height + " @ " + params.videoFps + "fps "
+ (settings.getVideoBitrate() / 1000) + "kbps"); + (params.videoBitrate / 1000) + "kbps GOP=" + params.keyframeIntervalSec);
return inputSurface; return inputSurface;
} }
private static MediaFormat buildFormat(int width, int height, CastSettings settings, boolean tuned) { private static MediaFormat buildFormat(int width, int height, String mime,
MediaFormat format = MediaFormat.createVideoFormat(MIME, width, height); 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_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
format.setInteger(MediaFormat.KEY_BIT_RATE, settings.getVideoBitrate()); format.setInteger(MediaFormat.KEY_BIT_RATE, params.videoBitrate);
format.setInteger(MediaFormat.KEY_FRAME_RATE, settings.getVideoFps()); format.setInteger(MediaFormat.KEY_FRAME_RATE, params.videoFps);
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, CastConfig.VIDEO_I_FRAME_INTERVAL); format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, params.keyframeIntervalSec);
if (!tuned) { if (!tuned || !params.requestLowLatency) {
return format; return format;
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
@@ -80,7 +82,7 @@ public class VideoEncoder {
format.setInteger(MediaFormat.KEY_PRIORITY, 0); format.setInteger(MediaFormat.KEY_PRIORITY, 0);
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
format.setInteger(MediaFormat.KEY_LOW_LATENCY, 1); format.setInteger(MediaFormat.KEY_LOW_LATENCY, 1);

View File

@@ -10,6 +10,7 @@
android:padding="16dp"> android:padding="16dp">
<TextView <TextView
android:id="@+id/text_sender_title"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/sender_title" android:text="@string/sender_title"
@@ -25,6 +26,50 @@
<include layout="@layout/activity_settings_panel" /> <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 <EditText
android:id="@+id/edit_pin" android:id="@+id/edit_pin"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -45,13 +90,27 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal"> android:orientation="horizontal">
<Button <FrameLayout
android:id="@+id/btn_refresh"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:layout_weight="1" android:layout_weight="1">
android:text="@string/refresh" />
<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 <Button
android:id="@+id/btn_start_cast" android:id="@+id/btn_start_cast"

View File

@@ -22,6 +22,39 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" /> 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 <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -33,6 +66,17 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" /> 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 <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@@ -28,7 +28,26 @@
<string name="resolution_full">Full display</string> <string name="resolution_full">Full display</string>
<string name="resolution_75">75% scale</string> <string name="resolution_75">75% scale</string>
<string name="resolution_half">50% 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="transport_mismatch">Transport must match the receiver (TCP/UDP)</string>
<string name="select_receiver">Select a receiver first</string> <string name="select_receiver">Select a receiver first</string>
<string name="enter_pin">Enter PIN</string> <string name="enter_pin">Enter PIN</string>
@@ -37,6 +56,16 @@
<string name="found_receivers">Found %1$d receiver(s)</string> <string name="found_receivers">Found %1$d receiver(s)</string>
<string name="receiver_ready">Ready — PIN %1$s · %2$s</string> <string name="receiver_ready">Ready — PIN %1$s · %2$s</string>
<string name="casting_from">Casting from %1$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="playing_resolution">Playing %1$dx%2$d</string>
<string name="stop">Stop</string> <string name="stop">Stop</string>
<string name="label_receiver_audio">Play incoming cast audio on this device</string> <string name="label_receiver_audio">Play incoming cast audio on this device</string>